[AI] Add api.importBudget() and api.exportBudget() (#8414)

* [AI] Add api.importBudget() and api.exportBudget()

Add first-class import/export functions to @actual-app/api so consumers
no longer have to drive the low-level worker RPC and the engine
filesystem by hand.

- api.importBudget(pathOrBytes, { type?, filename? }) imports an Actual
  .zip export (or a YNAB4/YNAB5 export), loads it, and resolves with the
  imported budget id. Raw bytes are passed straight to the import-budget
  handler instead of the upload-file-web temp-file dance, so it works in
  both the Node and browser builds and leaves no temp files behind.
- api.exportBudget() exports the currently-loaded budget and resolves
  with the zip contents as a Uint8Array.
- Both reject with an Error on failure instead of resolving { error }.
- The import-budget handler now optionally accepts raw file contents
  (buffer + filename) alongside the existing filepath input, and returns
  the id of the loaded budget on success; existing callers are
  unaffected.
- Add round-trip, file-path, and failure integration tests; update the
  API reference docs.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014wq2oMcHb99mLb9qpaR8SN

* [AI] Move ImportableBudgetType re-export to models.ts

Type re-exports for API consumers live in models.ts (like Preferences),
not methods.ts — follow the existing pattern.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014wq2oMcHb99mLb9qpaR8SN

* [AI] Address review: relax error assertions, document type union

- Assert importBudget failures on the stable "Error importing budget:"
  prefix instead of backend-specific error codes
- Document options.type as the 'actual' | 'ynab4' | 'ynab5' union in the
  API reference instead of a generic string

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014wq2oMcHb99mLb9qpaR8SN

---------

Co-authored-by: Claude <[email protected]>
This commit is contained in:
Matiss Janis Aboltins
2026-07-06 17:42:45 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent c64c0aa049
commit 168fe0d68b
6 changed files with 195 additions and 7 deletions
+86
View File
@@ -75,6 +75,92 @@ describe('API setup and teardown', () => {
});
});
describe('API budget import/export', () => {
const importedBudgetIds = new Set<string>();
beforeEach(async () => {
await api.loadBudget(budgetName);
});
afterEach(async () => {
// Close any budget loaded by an import before removing its files
await api.shutdown();
for (const id of importedBudgetIds) {
await fs.rm(path.join(__dirname, '/mocks/budgets/', id), {
force: true,
recursive: true,
});
}
importedBudgetIds.clear();
});
// apis: exportBudget, importBudget
test('exportBudget returns bytes that importBudget loads back', async () => {
const accountId = await api.createAccount({ name: 'round-trip' }, 0);
await api.addTransactions(accountId, [
{ date: '2023-10-01', amount: 1234, notes: 'round-trip transaction' },
]);
const data = await api.exportBudget();
expect(data).toBeInstanceOf(Uint8Array);
expect(data.length).toBeGreaterThan(0);
const { id } = await api.importBudget(data);
expect(id).toBeTruthy();
importedBudgetIds.add(id);
// The imported budget is now the loaded one; verify the data
// survived the round-trip
const accounts = await api.getAccounts();
expect(accounts).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: accountId, name: 'round-trip' }),
]),
);
const transactions = await api.getTransactions(
accountId,
'2023-10-01',
'2023-10-31',
);
expect(transactions).toEqual(
expect.arrayContaining([
expect.objectContaining({
amount: 1234,
notes: 'round-trip transaction',
}),
]),
);
});
// apis: exportBudget, importBudget
test('importBudget imports from a file path', async () => {
const data = await api.exportBudget();
const filepath = path.join(__dirname, '/mocks/budgets/', 'export.zip');
await fs.writeFile(filepath, data);
try {
const { id } = await api.importBudget(filepath);
expect(id).toBeTruthy();
importedBudgetIds.add(id);
} finally {
await fs.rm(filepath, { force: true });
}
});
// apis: importBudget
test('importBudget rejects with an Error on failure', async () => {
await expect(api.importBudget('/does/not/exist.zip')).rejects.toThrow(
/^Error importing budget:/,
);
await expect(api.importBudget(new Uint8Array([1, 2, 3]))).rejects.toThrow(
/^Error importing budget:/,
);
});
});
describe('API CRUD operations', () => {
beforeEach(async () => {
// load test budget
+56
View File
@@ -8,6 +8,7 @@ import type {
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 {
@@ -49,6 +50,61 @@ 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');
}
+1
View File
@@ -1,2 +1,3 @@
export type * from '@actual-app/core/server/api-models';
export type { ImportableBudgetType } from '@actual-app/core/server/importers/index';
export type { SyncedPrefs as Preferences } from '@actual-app/core/types/prefs';
+14
View File
@@ -103,6 +103,8 @@ import APIList from './APIList';
"getBudgets",
"loadBudget",
"downloadBudget",
"importBudget",
"exportBudget",
"batchBudgetUpdates",
"runQuery",
"getIDByName",
@@ -820,6 +822,18 @@ Load a locally cached budget file.
Load a budget file. If the file exists locally, it will load from there. Otherwise, it will download the file from the server.
#### `importBudget`
<Method name="importBudget" args={[{ name: 'input', type: 'string | ArrayBuffer | Uint8Array' }, { name: 'options', type: "{ type?: 'actual' | 'ynab4' | 'ynab5', filename?: string }?" }]} returns="Promise<{ id: string }>" />
Import a budget from an exported file and load it. `input` is either a path to the file or the raw file contents. By default the file is treated as an Actual export (a `.zip` file containing `db.sqlite` and `metadata.json`); pass `type: 'ynab4'` or `type: 'ynab5'` to import a YNAB export instead. When passing raw contents, you can supply the original file name with `filename` — some import types use it to derive the budget name. Returns the id of the imported budget, which is now the loaded budget.
#### `exportBudget`
<Method name="exportBudget" args={[]} returns="Promise<Uint8Array>" />
Export the currently loaded budget as raw bytes in the zip format. You can save the bytes to a `.zip` file, or pass them back to `importBudget` to restore the budget later.
#### `batchBudgetUpdates`
<Method name="batchBudgetUpdates" args={[{ name: 'func', type: 'func' }]} returns="Promise<void>" />
@@ -467,19 +467,44 @@ async function createBudget({
async function importBudget({
filepath,
buffer,
filename,
type,
}: {
filepath: string;
type: ImportableBudgetType;
}): Promise<{ error?: string }> {
/** Path to the file to import, on the engine's filesystem. */
filepath?: string;
/** Raw contents of the file to import; alternative to `filepath`. */
buffer?: ArrayBuffer;
/**
* Original name of the imported file; only used together with `buffer`,
* to derive the budget name for some import types.
*/
filename?: string;
}): Promise<{ error?: string; id?: string }> {
try {
if (!(await fs.exists(filepath))) {
throw new Error(`File not found at the provided path: ${filepath}`);
let contents: Buffer;
let name: string;
if (filepath != null) {
if (!(await fs.exists(filepath))) {
throw new Error(`File not found at the provided path: ${filepath}`);
}
contents = Buffer.from(await fs.readFile(filepath, 'binary'));
name = filepath;
} else if (buffer != null) {
contents = Buffer.from(buffer);
name = filename || 'budget-import';
} else {
throw new Error('Either `filepath` or `buffer` must be given');
}
const buffer = Buffer.from(await fs.readFile(filepath, 'binary'));
const results = await handleBudgetImport(type, filepath, buffer);
return results || {};
const results = await handleBudgetImport(type, name, contents);
if (results && results.error) {
return results;
}
// A successful import leaves the imported budget loaded
return { id: prefs.getPrefs()?.id };
} catch (err) {
err.message = 'Error importing budget: ' + err.message;
captureException(err);
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Add importBudget and exportBudget functions to the API for importing and exporting budget files directly