Files
actual/packages/api/methods.ts
f36804f046 [AI] Return what a bank sync reconciled from the API's runBankSync (#8998)
* Return what a bank sync reconciled from the API's runBankSync

`accounts-bank-sync` and `simplefin-batch-sync` already reconcile each
account and report the transactions they added and matched, but
`api/bank-sync` destructured `{ errors }` out of that and returned nothing.
An API consumer could tell a sync had succeeded, not whether it had done
anything, so tools built on the API either notify on every scheduled run or
diff the transaction table themselves to find out.

The handler now collects `newTransactions`, `matchedTransactions` and
`updatedAccounts` across both the single-account and batch paths and returns
them. Errors still throw, so a resolved promise continues to mean the sync
succeeded, and `void` to an object breaks no existing caller. This also
makes `runBankSync` consistent with `importTransactions`, which already
surfaces its reconciliation result.

Verified against a live sync server with a GoCardless-linked account, two
runs back to back:

  published 26.9.0   undefined
  first run          16 newTransactions, 0 matched, 1 updatedAccount
  second run         0, 0, 0

All 16 returned ids resolve to real transactions in the ledger. The second
run is the case this is for: successful, and nothing happened.

* Address review: real error fixtures, existing doc convention, bounded appends

Three fixes from the CodeRabbit review.

The docs entry named a `BankSyncResult` type that does not exist anywhere in
the repo. It now spells the shape inline, the way the neighbouring
`importTransactions` entry already does.

The test fixtures passed a bare string where a sync error goes, but a real
SyncError is an object and the handler reads two fields off it:
getBankSyncError(errors[0]) for the message and errors[0].code for the code
it attaches. The code half was therefore never exercised. Fixtures are now
typed SyncResponseWithErrors with a real error object, the mock reads
`.message` like the real helper, and a new test asserts the code reaches the
thrown error. Confirmed it fails if `withErrorCode` is dropped.

The id lists are appended one at a time instead of spread, since they are as
long as the sync is big. `allErrors` keeps its existing spread: it is bounded
by the account count, not by the transaction count.

* docs(api): type runBankSync's result and mark its argument optional

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YPyenH5t6DHfoqqkXLWfEn

* [AI] docs(api): correct runBankSync's updatedAccounts and note partial imports on rejection

updatedAccounts only lists accounts that received new transactions, not
accounts whose balance was refreshed. When syncing every account, one
failing account rejects the call after the others have already imported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011AwAzzZALs5RFb454j3cMV

* [AI] docs(api): say runBankSync's arrays are empty only when nothing was added or updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnubtGYPLiUFthYYeUxMaY

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:19:11 +00:00

471 lines
12 KiB
TypeScript

import { send } from '@actual-app/core/platform/client/connection';
import type {
APIAccountEntity,
APIAccountGroupEntity,
APICategoryEntity,
APICategoryGroupEntity,
APIFileEntity,
APIPayeeEntity,
APIRuleEntity,
APIScheduleEntity,
APITagEntity,
} from '@actual-app/core/server/api-models';
import type { ImportableBudgetType } from '@actual-app/core/server/importers/index';
import type { Query } from '@actual-app/core/shared/query';
import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers';
import type {
ImportTransactionEntity,
NoteEntity,
RuleEntity,
TransactionEntity,
} from '@actual-app/core/types/models';
import type { SyncedPrefs } from '@actual-app/core/types/prefs';
export { q } from './app/query';
export async function runImport(
budgetName: APIFileEntity['name'],
func: () => Promise<void>,
) {
await send('api/start-import', { budgetName });
try {
await func();
} catch (e) {
await send('api/abort-import');
throw e;
}
await send('api/finish-import');
}
export async function loadBudget(budgetId: string) {
return send('api/load-budget', { id: budgetId });
}
export async function downloadBudget(
syncId: string,
{ password }: { password?: string } = {},
) {
return send('api/download-budget', { syncId, password });
}
export async function getBudgets() {
return send('api/get-budgets');
}
/**
* Import a budget from an exported file — an Actual `.zip` export, or a
* YNAB4/YNAB5 export. Accepts either a path to the file on the engine's
* filesystem, or the raw file contents. Loads the imported budget and
* returns its id.
*/
export async function importBudget(
input: string | ArrayBuffer | Uint8Array,
{
type = 'actual',
filename,
}: { type?: ImportableBudgetType; filename?: string } = {},
): Promise<{ id: string }> {
const result =
typeof input === 'string'
? await send('import-budget', { filepath: input, type })
: await send('import-budget', {
buffer: toArrayBuffer(input),
filename,
type,
});
if (result.error) {
throw new Error(`Error importing budget: ${result.error}`);
}
if (!result.id) {
throw new Error('Error importing budget: no budget was loaded');
}
return { id: result.id };
}
/** Export the currently-loaded budget as a zip buffer. */
export async function exportBudget(): Promise<Uint8Array> {
const result = await send('export-budget');
if ('error' in result) {
throw new Error(`Error exporting budget: ${result.error}`);
}
if (!result.data) {
throw new Error('Error exporting budget: no data was returned');
}
return new Uint8Array(result.data);
}
function toArrayBuffer(data: ArrayBuffer | Uint8Array): ArrayBuffer {
if (data instanceof Uint8Array) {
// Copy into a fresh ArrayBuffer so that views into a larger (possibly
// shared) buffer are not sent across the worker boundary as-is.
const copy = new Uint8Array(data.byteLength);
copy.set(data);
return copy.buffer;
}
return data;
}
export async function sync() {
return send('api/sync');
}
/**
* Run bank sync for one account, or for every linked account when no
* `accountId` is given. Resolves with the ids of the transactions the sync
* reconciled, so callers can tell an idle sync from one that imported
* something without querying the transaction table themselves.
*/
export async function runBankSync(args?: {
accountId: APIAccountEntity['id'];
}) {
return send('api/bank-sync', args);
}
export async function batchBudgetUpdates(func: () => Promise<void>) {
await send('api/batch-budget-start');
try {
await func();
} finally {
await send('api/batch-budget-end');
}
}
/**
* @deprecated Please use `aqlQuery` instead.
* This function will be removed in a future release.
*/
export function runQuery(query: Query) {
return send('api/query', { query: query.serialize() });
}
export function aqlQuery(query: Query) {
return send('api/query', { query: query.serialize() });
}
export function getBudgetMonths() {
return send('api/budget-months');
}
export function getBudgetMonth(month: string) {
return send('api/budget-month', { month });
}
export function setBudgetAmount(
month: string,
categoryId: APICategoryEntity['id'],
value: number,
) {
return send('api/budget-set-amount', { month, categoryId, amount: value });
}
export function setBudgetCarryover(
month: string,
categoryId: APICategoryEntity['id'],
flag: boolean,
) {
return send('api/budget-set-carryover', { month, categoryId, flag });
}
export function addTransactions(
accountId: APIAccountEntity['id'],
transactions: Omit<ImportTransactionEntity, 'account'>[],
{
learnCategories = false,
runTransfers = false,
}: { learnCategories?: boolean; runTransfers?: boolean } = {},
) {
return send('api/transactions-add', {
accountId,
transactions,
learnCategories,
runTransfers,
});
}
export function importTransactions(
accountId: APIAccountEntity['id'],
transactions: ImportTransactionEntity[],
opts: ImportTransactionsOpts = {
defaultCleared: true,
dryRun: false,
},
) {
return send('api/transactions-import', {
accountId,
transactions,
isPreview: opts.dryRun,
opts,
});
}
export function getTransactions(
accountId: APIAccountEntity['id'],
startDate: string,
endDate: string,
) {
return send('api/transactions-get', { accountId, startDate, endDate });
}
export function updateTransaction(
id: TransactionEntity['id'],
fields: Partial<TransactionEntity>,
) {
return send('api/transaction-update', { id, fields });
}
export function deleteTransaction(id: TransactionEntity['id']) {
return send('api/transaction-delete', { id });
}
export function mergeTransactions(
ids: [TransactionEntity['id'], TransactionEntity['id']],
) {
return send('api/transactions-merge', { ids });
}
export function getAccounts() {
return send('api/accounts-get');
}
export function createAccount(
account: Omit<APIAccountEntity, 'id'>,
initialBalance?: number,
) {
return send('api/account-create', { account, initialBalance });
}
export function updateAccount(
id: APIAccountEntity['id'],
fields: Partial<APIAccountEntity>,
) {
return send('api/account-update', { id, fields });
}
export function closeAccount(
id: APIAccountEntity['id'],
transferAccountId?: APIAccountEntity['id'],
transferCategoryId?: APICategoryEntity['id'],
) {
return send('api/account-close', {
id,
transferAccountId,
transferCategoryId,
});
}
export function reopenAccount(id: APIAccountEntity['id']) {
return send('api/account-reopen', { id });
}
export function deleteAccount(id: APIAccountEntity['id']) {
return send('api/account-delete', { id });
}
export function getAccountBalance(id: APIAccountEntity['id'], cutoff?: Date) {
return send('api/account-balance', { id, cutoff });
}
export function getAccountGroups() {
return send('api/account-groups-get');
}
export function createAccountGroup(group: Omit<APIAccountGroupEntity, 'id'>) {
return send('api/account-group-create', { group });
}
export function updateAccountGroup(
id: APIAccountGroupEntity['id'],
fields: Partial<Omit<APIAccountGroupEntity, 'id'>>,
) {
return send('api/account-group-update', { id, fields });
}
export function deleteAccountGroup(id: APIAccountGroupEntity['id']) {
return send('api/account-group-delete', { id });
}
export function getCategoryGroups(options: { hidden?: boolean } = {}) {
return send('api/category-groups-get', options);
}
export function createCategoryGroup(group: Omit<APICategoryGroupEntity, 'id'>) {
return send('api/category-group-create', { group });
}
export function updateCategoryGroup(
id: APICategoryGroupEntity['id'],
fields: Partial<APICategoryGroupEntity>,
) {
return send('api/category-group-update', { id, fields });
}
export function deleteCategoryGroup(
id: APICategoryGroupEntity['id'],
transferCategoryId?: APICategoryEntity['id'],
) {
return send('api/category-group-delete', { id, transferCategoryId });
}
export function getCategories(options: { hidden?: boolean } = {}) {
return send('api/categories-get', options);
}
export function createCategory(category: Omit<APICategoryEntity, 'id'>) {
return send('api/category-create', { category });
}
export function updateCategory(
id: APICategoryEntity['id'],
fields: Partial<APICategoryEntity>,
) {
return send('api/category-update', { id, fields });
}
export function deleteCategory(
id: APICategoryEntity['id'],
transferCategoryId?: APICategoryEntity['id'],
) {
return send('api/category-delete', { id, transferCategoryId });
}
export function getNote(id: NoteEntity['id']) {
return send('api/note-get', { id });
}
export function updateNote(id: NoteEntity['id'], note: NoteEntity['note']) {
return send('api/note-update', { id, note });
}
export function getCommonPayees() {
return send('api/common-payees-get');
}
export function getPayees() {
return send('api/payees-get');
}
export function createPayee(payee: Omit<APIPayeeEntity, 'id'>) {
return send('api/payee-create', { payee });
}
export function updatePayee(
id: APIPayeeEntity['id'],
fields: Partial<APIPayeeEntity>,
) {
return send('api/payee-update', { id, fields });
}
export function deletePayee(id: APIPayeeEntity['id']) {
return send('api/payee-delete', { id });
}
export function getTags() {
return send('api/tags-get');
}
export function createTag(tag: Omit<APITagEntity, 'id'>) {
return send('api/tag-create', { tag });
}
export function updateTag(
id: APITagEntity['id'],
fields: Partial<Omit<APITagEntity, 'id'>>,
) {
return send('api/tag-update', { id, fields });
}
export function deleteTag(id: APITagEntity['id']) {
return send('api/tag-delete', { id });
}
export function mergePayees(
targetId: APIPayeeEntity['id'],
mergeIds: APIPayeeEntity['id'][],
) {
return send('api/payees-merge', { targetId, mergeIds });
}
export function getRules() {
return send('api/rules-get');
}
export function getPayeeRules(id: RuleEntity['id']) {
return send('api/payee-rules-get', { id });
}
export function createRule(rule: Omit<APIRuleEntity, 'id'>) {
return send('api/rule-create', { rule });
}
export function updateRule(rule: APIRuleEntity) {
return send('api/rule-update', { rule });
}
export function deleteRule(id: RuleEntity['id']) {
return send('api/rule-delete', id);
}
export function holdBudgetForNextMonth(month: string, amount: number) {
return send('api/budget-hold-for-next-month', { month, amount });
}
export function resetBudgetHold(month: string) {
return send('api/budget-reset-hold', { month });
}
export function createSchedule(schedule: Omit<APIScheduleEntity, 'id'>) {
return send('api/schedule-create', schedule);
}
export function updateSchedule(
id: APIScheduleEntity['id'],
fields: Partial<APIScheduleEntity>,
resetNextDate?: boolean,
) {
return send('api/schedule-update', {
id,
fields,
resetNextDate,
});
}
export function deleteSchedule(scheduleId: APIScheduleEntity['id']) {
return send('api/schedule-delete', scheduleId);
}
export function getSchedules() {
return send('api/schedules-get');
}
export function getScheduleDates(
date: APIScheduleEntity['date'],
options: { start?: string; end?: string; count?: number } = {},
) {
return send('api/schedule-dates-get', { date, ...options });
}
export function getIDByName(
type: 'accounts' | 'schedules' | 'categories' | 'payees',
name: string,
) {
return send('api/get-id-by-name', { type, name });
}
export function getServerVersion() {
return send('api/get-server-version');
}
/** Read the budget's synced preferences (number format, currency, etc.). */
export function getPreferences(): Promise<SyncedPrefs> {
return send('preferences/get');
}
export function setPreference<T extends keyof SyncedPrefs>(
id: T,
value: SyncedPrefs[T] | undefined,
): Promise<void> {
return send('preferences/save', { id, value });
}