const { mkdir } = require('node:fs/promises'); import * as api from "@actual-app/api"; import { ServerConfig } from "@/types/config"; import { Transaction } from "@/types/transaction"; import { enhancedStringConfig } from "@/util/config"; import { utils } from "@actual-app/api"; export type ActualImportResult = Awaited>; export type SubmitOptions = | { mode: 'add', learnCategories?: boolean, runTransfers?: boolean } | { mode: 'import' } | { mode: 'dry-run' }; type ActualTransaction = { id?: string; account: string; date: string; amount: number; payee?: string; payee_name?: string; imported_payee?: string; category_id?: string; notes?: string; imported_id?: string; transfer_id?: string; cleared?: boolean; subtransactions?: ActualTransaction[]; }; type ActualAccount = { id: string; name: string; type: string; offBudget: boolean; closed: boolean; }; type ActualPayee = { id: string; name: string; category?: string; transfer_acct?: string; }; export class Actual { #config: ServerConfig; constructor(config: ServerConfig) { this.#config = config; } #map(transaction: Transaction, accounts: ActualAccount[], payees: ActualPayee[]): ActualTransaction { const actualTransaction: Omit & { account?: string } = { imported_id: transaction.id, date: transaction.date, amount: utils.amountToInteger(transaction.amount), notes: transaction.title, imported_payee: transaction.toDetails, cleared: false }; const findAccount = (name: string) => { const accountName = this.#config.accountAliases?.[name] ?? name; const account = accounts.find(a => a.name === accountName); if (!account) { throw new Error(`Unknown account: ${accountName}`); } return account; } const findTransferPayee = (name: string) => { const account = findAccount(name); const payee = payees.find(p => p.transfer_acct === account.id); if (!payee) { throw new Error(`Transfer payee not found for account ${name} with ID ${account.id}`); } return payee; } switch(transaction.kind) { case 'regular': actualTransaction.account = findAccount(transaction.from).id; actualTransaction.payee_name = transaction.to; break; case 'transfer': actualTransaction.account = findAccount(transaction.to).id; actualTransaction.payee = findTransferPayee(transaction.from).id; break; } return actualTransaction as ActualTransaction; } async #api(fn: () => Promise): Promise { try { await mkdir(this.#config.data, { recursive: true }); } catch(e) {} await api.init({ serverURL: this.#config.url, password: enhancedStringConfig(this.#config.password), dataDir: this.#config.data }); await api.downloadBudget(this.#config.budget); try { return await fn(); } finally { await api.shutdown(); }; } async #printTransactions(transactions: ActualTransaction[]) { const data: { value: (t: ActualTransaction) => string|undefined, header: string, pad: number }[] = [ { value: t => t.payee !== undefined ? "Yes" : "No", header: "Known payee", pad: 12 }, { value: t => t.imported_payee, header: "Imported payee", pad: 70 }, { value: t => t.amount?.toString(), header: "Amount", pad: 10 }, { value: t => t.notes, header: "Notes", pad: 100 } ]; const header = data.map(d => d.header.padEnd(d.pad)).join(" "); const rows = transactions.map(transaction => data.map(d => (d.value(transaction) ?? "").padEnd(d.pad)).join(" ")); console.log(header); console.log("-".repeat(Math.max(...rows.map(x => x.length)) ?? header.length)); rows.forEach(x => console.log(x)); } async #submitTransactions(transactions: ActualTransaction[], add: (t: ActualTransaction) => Promise): Promise { const output: ActualImportResult = { added: [], updated: [], errors: [] }; for (const transaction of transactions) { const result = await add(transaction); output.added.push(...result.added); output.updated.push(...result.updated); output.errors?.push(...(result.errors ?? [])); await sleep(100); } return output; } async #doSubmit(transactions: ActualTransaction[], opts: SubmitOptions): Promise { switch(opts.mode) { case 'add': return this.#submitTransactions(transactions, async t => { const result = await api.addTransactions(t.account, [t], opts); return result === "ok" ? { added: [t], updated: [], errors: [] } : { added: [], updated: [], errors: [{ message: `Cannot add transaction: ${t}` }] }; }); case 'import': return this.#submitTransactions(transactions, t => api.importTransactions(t.account, [t])); } this.#printTransactions(transactions); return { added: [], updated: [], errors: [] }; } async prepare(transactions: Transaction[]): Promise { const accounts: ActualAccount[] = await api.getAccounts(); const payees: ActualPayee[] = await api.getPayees(); return transactions.map(t => this.#map(t, accounts, payees)); } async submit(transactions: ActualTransaction[], opts: SubmitOptions): Promise { return this.#api(() => this.#doSubmit(transactions, opts)); } async load(transactions: Transaction[], opts: SubmitOptions): Promise { return this.#api(async () => { const prepared = await this.prepare(transactions); return this.#doSubmit(prepared, opts); }); } async getTransactions(limit: number): Promise { return this.#api(async () => { const accounts: ActualAccount[] = await api.getAccounts(); const payees: ActualPayee[] = await api.getPayees(); const query = api.q('transactions') .limit(limit) .select(['*']) .options({ splits: 'grouped' }); const { data } = await api.runQuery(query) as { data: ActualTransaction[] }; return this.#mapActualTransactionsToTransactions(data, accounts, payees); }); } async getTransactionsFromRange(start: string, end: string): Promise { return this.#api(async () => { const accounts: ActualAccount[] = await api.getAccounts(); const payees: ActualPayee[] = await api.getPayees(); const query = api.q('transactions') .filter({ date: [{ $gte: start }, { $lte: end }] }) .select(['*']) .options({ splits: 'grouped' }); const { data } = await api.runQuery(query) as { data: ActualTransaction[] }; return this.#mapActualTransactionsToTransactions(data, accounts, payees); }); } #mapActualTransactionsToTransactions(transactions: ActualTransaction[], accounts: ActualAccount[], payees: ActualPayee[]): Transaction[] { const transfers: string[] = []; return transactions.map(t => { const account = accounts.find(a => a.id === t.account)?.name ?? t.account ?? "--unknown--"; const payee = payees.find(p => p.id === t.payee)?.name ?? t.payee ?? "--unknown--"; let from = account; let to = payee; if (t.amount && t.amount > 0) { from = payee; to = account; } if (t.transfer_id) { if (transfers.includes(t.id!!)) { return undefined; } transfers.push(t.transfer_id); } return { kind: t.transfer_id ? 'transfer' : 'regular', date: t.date, from, to, fromDetails: from, toDetails: to, amount: (t.amount ?? 0)/100, title: t.notes } as Transaction; }).filter(t => t !== undefined); } } async function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); }