261 lines
8.0 KiB
TypeScript
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));
|
|
} |