Add support for submitting the transactions to Actual server

This commit is contained in:
2025-04-01 17:19:50 +02:00
parent 9fa3b7d775
commit 32014f84e8
10 changed files with 155 additions and 18 deletions

View File

@@ -9,6 +9,7 @@ export function run(...args: string[]) {
.version("0.0.1")
.requiredOption("-c, --config <file>", "sets the path to the YAML file with configuration")
.option("-p, --profile <name>", "sets the desired profile to invoke")
.option("-s, --server <name>", "sets the desired server to upload transactions to")
.option("-x, --set <arg>", "overrides the config option for this specific run (arg: <key>=<name>, i.e. profiles.myprofile.parser=pl.ing", (v: string, prev: string[]) => prev.concat([v]), [])
.argument('<csv-file>', 'CSV file to be imported')
.action(handle)
@@ -41,5 +42,12 @@ function handle(file: string, options: CLIOptions) {
throw new Error(`No profiles defined in the ${options.config}`);
}
loadTransactions(file, options.profile ?? config.defaultProfile ?? Object.keys(config.profiles)[0], config);
if (Object.keys(config?.servers ?? {}).length === 0) {
throw new Error(`No servers defined in the ${options.config}`);
}
const profile = options.profile ?? config.defaultProfile ?? Object.keys(config.profiles)[0];
const server = options.server ?? config.defaultServer ?? Object.keys(config.servers)[0];
loadTransactions(file, profile, server, config);
}

View File

@@ -1,5 +1,6 @@
import { Transaction } from "@/types/transaction";
import { ProfileConfig, ParserConfig } from "@/types/config";
import { utils } from "@actual-app/api";
export abstract class BaseTransactionParser<C extends ParserConfig> {
public readonly name: string;
@@ -30,6 +31,12 @@ export abstract class BaseTransactionParser<C extends ParserConfig> {
return undefined;
}
return Number.parseFloat(input.replaceAll(",", "."));
const v = utils.amountToInteger(Number.parseFloat(input.replaceAll(",", ".")));
if (isNaN(v)) {
return undefined;
}
return v;
}
}

View File

@@ -1,7 +1,6 @@
import { Transaction } from "@/types/transaction";
import { ParserConfig } from "@/types/config";
import { BaseTransactionParser } from "../..";
import { Parser } from "papaparse";
const headers = [
'transactionDate',
@@ -38,7 +37,7 @@ const readIngTransaction = (data: string[]): IngTransaction|undefined => {
const transaction: IngTransaction = {};
headers.forEach((key, index) => {
transaction[key] = data[index];
transaction[key] = data[index].trim();
});
return transaction;
@@ -54,10 +53,21 @@ export default class extends BaseTransactionParser<ParserConfig> {
return undefined;
}
// Validate
if (!/^\d{4}-\d{2}-\d{2}$/.test(data[0])) {
return undefined;
}
const amount = this.parseAmount(ing.transactionAmount);
if(amount === undefined) {
return undefined;
}
return {
account: "TODO: unknown account (not supported yet)",
date: ing.transactionDate,
amount: this.parseAmount(ing.transactionAmount),
amount,
notes: ing.title,
date: ing.transactionDate,
imported_payee: ing.contrahentData?.trim()?.replaceAll(/\s+/g, " ")
}
}

View File

@@ -1,18 +1,51 @@
import fs from "fs";
import { openCsv } from "@/csv";
import { createParser } from "@/parser";
import { Actual } from "@/server";
import { Config } from "@/types/config";
export function loadTransactions(file: string, profile: string, config: Config) {
export function loadTransactions(file: string, profile: string, server: string, config: Config) {
const profileConfig = config.profiles[profile];
if (!profileConfig) {
throw new Error(`Unknown profile: ${profile}`);
}
const serverConfig = config.servers[server];
if(!serverConfig) {
throw new Error(`Unknown server: ${server}`);
}
const parser = createParser(profileConfig);
openCsv(file).on('data', async data => {
const transaction = await parser.parseTransaction(profileConfig, data);
console.log(transaction);
});
const actualServer = new Actual(serverConfig);
const skipped: string[] = [];
openCsv(file)
.on('data', async data => {
const transaction = await parser.parseTransaction(profileConfig, data);
if (transaction === undefined) {
skipped.push(`Skipped ==> ${data.join(" ::: ")}`);
return;
}
actualServer.pushTransaction(transaction);
})
.on('close', () => actualServer.submit()
.then(x => {
console.log(`Inserted: ${x.added.length}`);
console.log(`Updated: ${x.updated.length}`);
console.log(`Errors: ${x.errors?.length}`);
console.log(`Skipped: ${skipped.length}`);
const now = new Date();
const date = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`;
const time = `${now.getHours().toString().padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}-${now.getSeconds().toString().padStart(2, '0')}`
const filename = `${serverConfig.data}/import.${profile}.${server}.${date}T${time}.json`.replaceAll(/\/+/g, "/");
const logContent = `${JSON.stringify(x, undefined, 2)}\n\n\n\n${skipped.join("\n")}`
fs.writeFileSync(filename, logContent);
console.log(`Detailed output written to ${filename} file.`);
})
.catch(x => console.error(x))
);
}

42
src/server/index.ts Normal file
View File

@@ -0,0 +1,42 @@
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";
type TransactionDTO = Transaction & {
account: string
};
export class Actual {
#transactions: TransactionDTO[];
#config: ServerConfig;
constructor(config: ServerConfig) {
this.#transactions = [];
this.#config = config;
}
pushTransaction(transaction: Transaction) {
this.#transactions.push({ ...transaction, account: this.#config.account });
}
async submit(): ReturnType<typeof api.importTransactions> {
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);
console.log(`Importing ${this.#transactions.length} transactions`);
const result = await api.importTransactions(this.#config.account, this.#transactions);
await api.shutdown();
return result;
}
}

View File

@@ -1,5 +1,6 @@
export type CLIOptions = {
config: string;
profile?: string;
server?: string;
set: string[];
};

View File

@@ -1,3 +1,11 @@
export type Config = {
defaultProfile?: string;
profiles: Record<string, ProfileConfig>;
defaultServer?: string;
servers: Record<string, ServerConfig>;
};
export type ProfileConfig = {
parser: string;
config?: ParserConfig;
@@ -7,7 +15,10 @@ export type ParserConfig = {
};
export type Config = {
defaultProfile?: string;
profiles: Record<string, ProfileConfig>;
export type ServerConfig = {
budget: string;
account: string;
url: string;
password: string;
data: string;
};

View File

@@ -1,6 +1,5 @@
export type Transaction = {
id?: string;
account: string;
id?: string;
date: string;
amount?: number;
payee?: string;

9
src/util/code.ts Normal file
View File

@@ -0,0 +1,9 @@
const standardContext = {
};
export function jsMapper<I, O>(code: string, context: Record<string, unknown>): (task: I) => O {
const ctx = { ...standardContext, ...context };
const filter = new Function('$', ...Object.keys(ctx), code);
return (task: I) => filter(task, ...Object.values(ctx));
}

17
src/util/config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { readFileSync } from "fs";
const specialOptions: Record<string, (text: string) => string> = {
$__file: (arg: string) => readFileSync(arg, 'utf8').trim()
};
export const enhancedStringConfig = (value: string) => {
const trimmed = value.trim();
for(const opt of Object.keys(specialOptions)) {
if(trimmed.startsWith(`${opt}:`) && opt in specialOptions) {
return specialOptions[opt](trimmed.slice(opt.length + 1).trim());
}
}
return trimmed;
};