Files
actual-importer/src/backend/index.ts

261 lines
8.0 KiB
TypeScript

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<ReturnType<typeof api.importTransactions>>;
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<ActualTransaction, 'account'> & { 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<T>(fn: () => Promise<T>): Promise<T> {
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<ActualImportResult>): Promise<ActualImportResult> {
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<ActualImportResult> {
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<ActualTransaction[]> {
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<ActualImportResult> {
return this.#api(() => this.#doSubmit(transactions, opts));
}
async load(transactions: Transaction[], opts: SubmitOptions): Promise<ActualImportResult> {
return this.#api(async () => {
const prepared = await this.prepare(transactions);
return this.#doSubmit(prepared, opts);
});
}
async getTransactions(limit: number): Promise<Transaction[]> {
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<Transaction[]> {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}