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
JavaScript
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();