UNPKG

ghostfolio-importer

Version:

`ghostfolio-importer` is a simple utility to import transactions from Scalable Capital and Trade Republic into Ghostfolio. Please check out our [repository](https://github.com/milesstoetzner/stoetzms-ghostfolio-importer).

498 lines (488 loc) 14.7 kB
#!/usr/bin/env node import { program } from 'commander'; import * as reader from 'pdf-text-reader'; import path from 'path'; import fs from 'node:fs/promises'; import * as yaml from 'js-yaml'; import axios from 'axios'; var TransactionType = /* @__PURE__ */ ((TransactionType2) => { TransactionType2["BUY"] = "BUY"; TransactionType2["SELL"] = "SELL"; TransactionType2["DIVIDEND"] = "DIVIDEND"; return TransactionType2; })(TransactionType || {}); function parseNumber(raw) { const parsed = Number.parseFloat(raw.replace(",", ".")); if (isNaN(parsed)) throw new Error(`raw "${raw}" not a number`); return parsed; } function parseDate(day, month, year) { const value = year + "-" + month + "-" + day + " 00:00:00 UTC"; const date = Date.parse(value); if (isNaN(date)) throw new Error(`invalid date: ${value}`); return new Date(date); } function convertPrice(price, rate) { if (isDefined(rate)) return price * (1 / rate); return price; } function isUndefined(element) { return typeof element === "undefined" || element === null || element == null; } function isDefined(element) { return !isUndefined(element); } class ScalableCapital { raw; file; constructor(raw, file) { this.raw = raw; this.file = file; } get data() { return { type: this.type, ISIN: this.ISIN, quantity: this.quantity, price: convertPrice(this.price, this.conversion), currency: this.conversion ? "EUR" : this.currency, date: this.date, _meta: { provider: "scalable-capital", format: "pdf", file: this.file } }; } _type; get type() { if (isUndefined(this._type)) { const { raw } = this.extract(/Wertpapierabrechnung: (?<raw>\S+)/); if (this.contains("Dividendenabrechnung") || this.contains("Fondsaussch\xFCttung")) { this._type = TransactionType.DIVIDEND; return this._type; } if (raw === "Kauf") { this._type = TransactionType.BUY; return this._type; } if (raw === "Verkauf") { this._type = TransactionType.SELL; return this._type; } throw new Error(`unknown type "${raw}"`); } return this._type; } _quantity; get quantity() { if (isUndefined(this._quantity)) { const { raw } = this.extract(/STK (?<raw>\S+)/); if (isUndefined(raw)) throw new Error("no quantity"); this._quantity = parseNumber(raw); } return this._quantity; } _conversion; _conversion_extracted = false; get conversion() { if (!this._conversion_extracted) { const { raw } = this.extract(/Umrechnungskurs:\s+EUR\/USD\s*(?<raw>\S+)/); if (raw) this._conversion = parseNumber(raw); this._conversion_extracted = true; } return this._conversion; } _price; get price() { if (isUndefined(this._price)) { const { raw: a } = this.extract(new RegExp(`Kurs\\n${this.currency}\\n(?<raw>\\S+)`)); const { raw: b } = this.extract(/(?<raw>\S+) p.STK/); const raw = a ?? b; if (isUndefined(raw)) throw new Error("no price"); this._price = parseNumber(raw); } return this._price; } _currency; get currency() { if (isUndefined(this._currency)) { const { raw: a } = this.extract(/Kurswert (?<raw>\S+)/); const { raw: b } = this.extract(/(?<raw>\S+)\s+\S+\s+p.STK/); const raw = a ?? b; if (raw === "EUR") { this._currency = "EUR"; return this._currency; } if (raw === "USD") { this._currency = "USD"; return this._currency; } throw new Error(`unknown currency: ${raw}`); } return this._currency; } _date; get date() { if (isUndefined(this._date)) { const a = this.extract(/Handelsdatum Handelsuhrzeit\n(?<day>\d\d)\.(?<month>\d\d)\.(?<year>\d\d\d\d)/); const b = this.extract( /Handels-\s+Handels-\ndatum\s+uhrzeit\nNominale\s+Kurs\s+Ausführungsplatz\n.*(?<day>\d\d)\.(?<month>\d\d)\.(?<year>\d\d\d\d)/ ); const c = this.extract(/Zahltag:\s+(?<day>\d\d)\.(?<month>\d\d)\.(?<year>\d\d\d\d)/); let raw; if (a.day && a.month && a.year) raw = a; if (b.day && b.month && b.year) raw = b; if (c.day && c.month && c.year) raw = c; if (raw === void 0) throw new Error(`no date`); this._date = parseDate(raw.day, raw.month, raw.year); } return this._date; } _ISIN; get ISIN() { if (isUndefined(this._ISIN)) { const { raw } = this.extract(/ISIN: (?<raw>\S+)/); if (isUndefined(raw)) throw new Error(`no ISIN`); this._ISIN = raw; } return this._ISIN; } contains(value) { return this.raw.includes(value); } extract(regex) { const result = regex.exec(this.raw); if (!result) return {}; return result.groups; } } class Ghostfolio { accountId; transactions; map; constructor(data) { this.accountId = data.accountId; this.transactions = data.transactions; this.map = data.map; } _data; get data() { if (!this._data) { this._data = { activities: this.transactions.map((it) => ({ accountId: this.accountId, type: it.type, symbol: this.mapToSymbol(it.ISIN), quantity: it.quantity, unitPrice: it.price, currency: it.currency, date: it.date.toISOString(), fee: 0, dataSource: "YAHOO", comment: "GENERATED" })) }; } return this._data; } mapToSymbol(ISIN) { const symbol = this.map[ISIN]; if (!symbol) throw new Error(`no symbol for ISIN: ${ISIN}`); return symbol; } } var std = { out: console.log, log: (...data) => { console.error("DEBUG", ...data); } }; class TradeRepublic { raw; file; constructor(raw, file) { this.raw = raw; this.file = file; } _data; get data() { if (isUndefined(this._data)) { this._data = { type: this.type, ISIN: this.ISIN, quantity: this.quantity, price: convertPrice(this.price, this.conversion), currency: this.conversion ? "EUR" : this.currency, date: this.date, _meta: { provider: "trade-republic", format: "pdf", file: this.file } }; } return this._data; } _type; get type() { if (isUndefined(this._type)) { if (this.contains("Dividende")) { this._type = TransactionType.DIVIDEND; return this._type; } if (this.contains("Order Kauf")) { this._type = TransactionType.BUY; return this._type; } if (this.contains("Order Verkauf")) { this._type = TransactionType.SELL; return this._type; } throw new Error(`no transaction type`); } return this._type; } _quantity; get quantity() { if (isUndefined(this._quantity)) { const { raw } = this.extract(/(?<raw>\S+) (Stk|Stücke)/); if (isUndefined(raw)) throw new Error("no quantity"); this._quantity = parseNumber(raw); } return this._quantity; } _conversion; _conversion_extracted = false; get conversion() { if (!this._conversion_extracted) { const { raw } = this.extract(/(?<raw>\S+)\s+USD\/EUR/); if (raw) this._conversion = parseNumber(raw); this._conversion_extracted = true; } return this._conversion; } _price; get price() { if (isUndefined(this._price)) { const { raw: a } = this.extract(/\S+\s+Stk\.\s+(?<raw>\S+)/); const { raw: b } = this.extract(/\S+\s+Stücke\s+(?<raw>\S+)/); const raw = a ?? b; if (isUndefined(raw)) throw new Error("no price"); this._price = parseNumber(raw); } return this._price; } _currency; get currency() { if (isUndefined(this._currency)) { const { raw } = this.extract(/\S+\s+(Stk\.|Stücke)\s+\S+\s+(?<raw>\S+)/); if (raw === "EUR") { this._currency = "EUR"; return this._currency; } if (raw === "USD") { this._currency = "USD"; return this._currency; } throw new Error(`unknown currency: ${raw}`); } return this._currency; } _date; get date() { if (isUndefined(this._date)) { const raw = this.extract( /(Order \S+ am |Dividende mit Ex-Datum)(?<day>\d\d)\.(?<month>\d\d)\.(?<year>\d\d\d\d)/ ); if (!raw.day || !raw.month || !raw.year) throw new Error(`no date`); this._date = parseDate(raw.day, raw.month, raw.year); } return this._date; } _ISIN; get ISIN() { if (isUndefined(this._ISIN)) { const { raw: a } = this.extract(/ISIN: (?<raw>\S+)/); const { raw: b } = this.extract(/\.*Stücke.*\n(?<raw>\S+)/); const raw = a ?? b; if (isUndefined(raw)) throw new Error(`no ISIN`); this._ISIN = raw; } return this._ISIN; } contains(value) { return this.raw.includes(value); } extract(regex) { const result = regex.exec(this.raw); if (!result) return {}; return result.groups; } } async function transform(options) { console.log(options); const transactions = await input(options); await output(transactions, options); } async function input(options) { switch (options.inputProvider) { case "scalable-capital": { if (options.inputFormat !== "pdf") throw new Error( `input provider "${options.inputProvider}" does not support input format "${options.inputFormat}"` ); const transactions = []; const files = await fs.readdir(path.resolve(options.inputDir)); for (const file of files) { if (!file.endsWith(".pdf")) continue; if (options.inputFilter) { if (!file.includes(options.inputFilter)) continue; } try { const joined = path.join(options.inputDir, file); const raw = await reader.readPdfText({ url: joined }); const extractor = new ScalableCapital(raw, joined); transactions.push(extractor.data); } catch (e) { console.log(file, e); } } return transactions; } case "trade-republic": { if (options.inputFormat !== "pdf") throw new Error( `input provider "${options.inputProvider}" does not support input format "${options.inputFormat}"` ); const transactions = []; const files = await fs.readdir(path.resolve(options.inputDir)); for (const file of files) { if (!file.endsWith(".pdf")) continue; if (options.inputFilter) { if (!file.includes(options.inputFilter)) continue; } try { const joined = path.join(options.inputDir, file); const raw = await reader.readPdfText({ url: joined }); const extractor = new TradeRepublic(raw, joined); transactions.push(extractor.data); } catch (e) { console.log(file, e); } } return transactions; } case "none": switch (options.inputFormat) { case "json": break; case "yaml": break; default: throw new Error( `input provider "${options.inputProvider}" does not support input format "${options.inputFormat}"` ); } break; default: throw new Error(`input provider "${options.inputProvider}" unknown`); } } async function output(transactions, options) { switch (options.outputProvider) { case "ghostfolio": { if (!options.ghostfolioAccountId) throw new Error(`no ghostfolio account id`); const map = options.ghostfolioMap ? JSON.parse(await fs.readFile(path.resolve(options.ghostfolioMap), "utf-8")) : {}; const ghost = new Ghostfolio({ accountId: options.ghostfolioAccountId, transactions, map: { IE00B1FZS798: "IUSM.DE", IE00B1TXK627: "IQQQ.DE", IE00B4L5Y983: "EUNL.DE", IE00B4WXJJ64: "EUNH.DE", IE00BYX2JD69: "SUSW.L", LU0908500753: "MEUD.PA", LU1681048804: "500.PA", LU1829221024: "UST.PA", ...map } }); switch (options.outputFormat) { case "console": std.log(ghost.data); break; case "json": await fs.writeFile(path.resolve(options.outputFile), JSON.stringify(ghost.data, null, 4)); break; case "yaml": await fs.writeFile( path.resolve(options.outputFile), yaml.dump(ghost.data, { noRefs: true, styles: { "!!null": "empty" } }) ); break; case "endpoint": { if (!options.ghostfolioEndpoint) throw new Error(`no ghostfolio endpoint`); if (!options.ghostfolioToken) throw new Error(`no ghostfolio token`); const response = await axios.post(options.ghostfolioEndpoint, ghost.data, { headers: { Authorization: `Bearer ${options.ghostfolioToken}` } }); std.log(response.data); break; } default: throw new Error( `output provider "${options.outputProvider}" does not support output format "${options.outputFormat}"` ); } break; } case "none": std.out(JSON.stringify(transactions, null, 4)); break; default: throw new Error(`unknown output format: ${options.outputFormat}`); } } var actions = { transform }; function exit(fn) { return async (options) => { try { await fn(options); } catch (error) { std.log({ error }); process.exit(1); } }; } function log(fn) { return async (args) => { try { await fn(args); } catch (error) { std.log({ error }); } }; } async function _try(fn, reason) { try { await fn(); } catch (error) { std.log(reason, { error }); } } var hae = { exit, log, try: _try }; program.command("transform").option("--input-dir <string>", "", ".").option("--input-file <string>", "").option("--input-filter <string>").option("--input-provider <string>", "", "scalable-capital").option("--input-format <string>", "", "pdf").option("--output-provider <string>", "", "ghostfolio").option("--output-format <string>", "", "json").option("--output-file <string>", "", "output.json").option("--ghostfolio-account-id <string>").option("--ghostfolio-map <string>").option("--ghostfolio-endpoint <string>").option("--ghostfolio-token <string>").action(hae.exit(async (options) => await actions.transform(options))); program.parse();