Add support for submitting the transactions to Actual server
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
42
src/server/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export type CLIOptions = {
|
||||
config: string;
|
||||
profile?: string;
|
||||
server?: string;
|
||||
set: string[];
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
9
src/util/code.ts
Normal 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
17
src/util/config.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user