Eine Datenbank mit meinen Kontobewegungen und eine Mail bei jeder Kontobewegung?

Manchmal habe ich eine Idee und suche nach der Lösung. Manchmal sehe ich aber auch zuerst die Lösung und habe dann die Idee. So auch als ich aus irgendeinem Grund über das Python Modul FinTS gestolpert bin.

Während ich schon seit einiger Zeit (über meinen Vater) weiß dass man eigentlich jedes deutsche EC Konto mittels einer Art API namens HBCI auslesen kann, so erfuhr ich das FinTS der Nachfolger ist. Und dass es ein sehr einfach zu bedienendes Python Modul dafür gibt…

Und als ich mir das so durchlas kam dann auch die Idee auf – warum speichere ich nicht alle meine Kontobewegungen vollautomatisch in einer MySQL Datenbank auf meinem Server? Und wenn ich da schon ein (Python) Programm laufen lasse welches meine Kontobewegungen holt – warum schreibt mir das nicht gleich eine Mail wenns was neues gibt?

Na dann: Auf ans Werk 🙂

Eine neue Datenbank ist schnell angelegt und eine Tabellenstruktur ausgedacht:

CREATE TABLE `konto`.`kontobewegungen` (
`bewegid` INT NOT NULL AUTO_INCREMENT,
`inserttime` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`hash` VARCHAR(128) NOT NULL DEFAULT '',
`onlyrequested` TINYINT NOT NULL DEFAULT 0,
`transaction_code` VARCHAR(32) NOT NULL DEFAULT '',
`transaction_details` TEXT NOT NULL DEFAULT '',
`prima_nota` VARCHAR(32) NOT NULL DEFAULT '',
`applicant_name` TEXT NOT NULL DEFAULT '',
`customer_reference` VARCHAR(32) NOT NULL DEFAULT '',
`extra_details` TEXT NOT NULL DEFAULT '',
`purpose` TEXT NOT NULL DEFAULT '',
`additional_purpose` TEXT NOT NULL DEFAULT '',
`applicant_bin` VARCHAR(32) NOT NULL DEFAULT '',
`applicant_iban` VARCHAR(32) NOT NULL DEFAULT '',
`funds_code` VARCHAR(32) NOT NULL DEFAULT '',
`date` DATE NOT NULL,
`return_debit_notes` VARCHAR(32) NOT NULL DEFAULT '',
`status` VARCHAR(32) NOT NULL DEFAULT '',
`currency` VARCHAR(32) NOT NULL DEFAULT '',
`posting_text` VARCHAR(32) NOT NULL DEFAULT '',
`recipient_name` VARCHAR(64) NOT NULL DEFAULT '',
`bank_reference` VARCHAR(32) NOT NULL DEFAULT '',
`amount` DECIMAL(15,3) NOT NULL DEFAULT '0',
`entry_date` DATE NOT NULL,
`id` VARCHAR(32) NOT NULL DEFAULT '',
`balance` DECIMAL(15,3) NOT NULL DEFAULT '0',
PRIMARY KEY (`bewegid`))
CHARACTER SET utf8 COLLATE utf8_unicode_ci,
ENGINE = InnoDB;

Das Tabellendesign ist ziemlich einfach und wahrscheinlich alles andere als effizient, aber meine privaten 500 Kontobewegungen am Tag lassen sich da noch leicht abbilden 😉

Interessant ist das Feld ‘hash’ welches einfach einen SHA512-Hash über alle relevanten Daten einer einzelnen Kontobewegung bildet und man mit diesem dann nachsehen kann ob wir eine bestimmte Buchung schon in der Datenbank haben.

Bei meiner Bank (der Postbank) ist es so dass diese über FinTS/HBCI in etwa die letzten drei Monate an Kontobewegungen anbietet – eine Art eindeutige ID gibt es aber nicht! Deshalb müssen wir uns mit dem Hash eine Möglichkeit bauen um eine Bewegung eindeutig identifizieren zu können.

Über das Feld gibt es natürlich einen Index zum schnellen Abfragen:

ALTER TABLE `postbankkonto`.`kontobewegungen` ADD UNIQUE `hashidx` (`hash`);

Das nächste erwähnenswerte Feld ist ‘onlyrequested’. Manchmal kommen Kontobewegungen mit Datum in der Zukunft vor. Diese sind “angefragte Buchungen” die keinen Einfluss auf den Saldo haben. Sie verschwinden auch nach einiger Zeit von selbst wieder. Wir heben sie trotzdem auf – weil wir es können!

Achja, der Saldo: Man kriegt über HBCI zwar den Saldo, aber nur den aktuellen. Man muss also den Saldo nach jeder Buchung selber ausrechnen. Naaaaa gut…

Erwähnenswert sind übrigens auch noch die Felder ‘transaction_code’ und ‘id’: Diese Felder bekomme ich so von der Bank und ich hab leider keine Ahnung was “105”, “117” oder “166” für den transaction_code und “N005”, “N010” oder “N051” für die id denn heißen soll. Man kann es sich zwar zusammenreimen (Das eine ist ne Gutschrift, das andere ne Lastschrift das nächste ein vorgemerkter Umsatz…) aber eine sinnvolle Erklärung scheint es nicht zu geben im großen weiten Internet.
Ich hab gerade mal eine Erklärung für irgendeine holländische Bank gefunden. Das passt aber nicht zu meinen Daten.
Es scheint mit der Transparenz bei den Banken nicht soweit her zu sein.

Achja, Transparenz: Fürs verbinden benötigt man seine Kontonummer, seine Telefon-Banking-Pin (zumindest bei der Postbank) und die API-Adresse. Diese API Adresse zu finden ist auch nochmal etwas schwierig aber für die Postbank heißt sie: “https://hbci.postbank.de/banking/hbci.do”.

Außerdem bekommt man übrigens alle Konten (und Depots) die man bei der Bank führt in einer Liste zurück. Ich frage hier aber nur mein eigentliches EC-Konto ab. (das ist das erste Konto in der Liste!)

Viel mehr als Bewegungen holen und Salden abfragen kann man wohl scheinbar mit dem FinTS nicht machen. Überweisungen tätigen kann man jedenfalls nicht. Aber das will ich auch garnicht. Besser dass das nicht geht 🙂

Und damit sich jetzt jeder über meine Programmierkenntnisse erfreuen kann, gibts hier den vollen Programmcode zum zerpflücken und kommentieren. Quasi ein Weihnachtsgeschenk 🙂

#!/usr/bin/python3
import re
import pymysql
import hashlib
import time
import smtplib
import datetime
from decimal import *
from email.mime.text import MIMEText
from fints.client import FinTS3PinTanClient

blz = '123'
account = '456'
pin = '789'
endpoint = 'https://hbci.postbank.de/banking/hbci.do'
mysqlhost = '127.0.0.1'
mysqluser = 'xxx'
mysqlpass = 'yyy'
mysqldb = 'zzz'
mailsender = 'banking@myserver.de'
mailreceiver = 'xxx@yyy.de'
mailserver = '127.0.0.1'
mailsubject = 'Kontobewegung!'

def mailtransfer(mailtext):
    message = ''
    message = message + mailtext

    msg = MIMEText(message, _charset="UTF-8")
    msg['Subject'] = mailsubject
    msg['From'] = mailsender
    msg['To'] = mailreceiver

    s = smtplib.SMTP(mailserver)
    s.send_message(msg)
    s.quit()

# Connect to the database
conn = pymysql.connect(host = mysqlhost, user = mysqluser, passwd = mysqlpass, db = mysqldb)

# Connect to the bank
f = FinTS3PinTanClient(blz, account, pin, endpoint)

# Get all acounts
konten = f.get_sepa_accounts()

# Get all Statements
# (We only get a few months anyway!)
bewegungen = f.get_statement(konten[0], datetime.date(2010, 10, 1), datetime.date.today())

# Loop through all statements
inserted = 0
for bewegung in bewegungen:
    # Hash this statement data
    myhash = ''
    myhash += str(bewegung.data.get('transaction_code', ''))
    myhash += str(bewegung.data.get('transaction_details', ''))
    myhash += str(bewegung.data.get('prima_nota', ''))
    myhash += str(bewegung.data.get('applicant_name', ''))
    myhash += str(bewegung.data.get('customer_reference', ''))
    myhash += str(bewegung.data.get('extra_details', ''))
    myhash += str(bewegung.data.get('purpose', ''))
    myhash += str(bewegung.data.get('additional_purpose', ''))
    myhash += str(bewegung.data.get('applicant_bin', ''))
    myhash += str(bewegung.data.get('applicant_iban', ''))
    myhash += str(bewegung.data.get('funds_code', ''))
    myhash += str(bewegung.data.get('date', ''))
    myhash += str(bewegung.data.get('return_debit_notes', ''))
    myhash += str(bewegung.data.get('status', ''))
    myhash += str(bewegung.data.get('currency', ''))
    myhash += str(bewegung.data.get('posting_text', ''))
    myhash += str(bewegung.data.get('recipient_name', ''))
    myhash += str(bewegung.data.get('bank_reference', ''))
    myhash += str(bewegung.data.get('amount', ''))
    myhash += str(bewegung.data.get('entry_date', ''))
    myhash += str(bewegung.data.get('id', '')) 
    myhash = hashlib.sha512(myhash.encode('UTF-8')).hexdigest()

    # Check if we already got this hash
    cur = conn.cursor()
    query = "SELECT 1 from kontobewegungen where hash = '" + myhash + "';"
    cur.execute(query)

    rowcount = cur.rowcount
    if rowcount == 0:
        # Get the amount (some Regexp needs to be done here, because it is something like '<-1.65 EUR>')
        amount = ''
        p = re.compile('[-+]?\d*\.\d+|[-+]?\d+')
        m = p.search(str(bewegung.data['amount']))
        amount = m.group()
        # Check if it's for the feature (amount is only requested, not yet really booked!)
        onlyrequested = 0
        bookingdate = datetime.datetime.strptime(str(bewegung.data.get('date', '')), '%Y-%m-%d').date()
        if bookingdate > datetime.date.today():
            onlyrequested = 1
        # Insert it!
        cur = conn.cursor()
        query = ''
        query += "INSERT INTO kontobewegungen (hash, onlyrequested, transaction_code, transaction_details, prima_nota, applicant_name, customer_reference, extra_details,"
        query += "purpose, additional_purpose, applicant_bin, applicant_iban, funds_code, date, return_debit_notes, status, currency, "
        query += "posting_text, recipient_name, bank_reference, amount, entry_date, id) "
        query += "VALUES ('" + myhash + "', " + str(onlyrequested) + ", '" + str(bewegung.data.get('transaction_code', '')) + "', '" +str(bewegung.data.get('transaction_details', '')) + "', '"+ str(bewegung.data.get('prima_nota', '')) + "', '"
        query += str(bewegung.data.get('applicant_name', '')) + "', '" + str(bewegung.data.get('customer_reference', '')) + "', '" + str(bewegung.data.get('extra_details', '')) + "', '"
        query += str(bewegung.data.get('purpose', '')) + "', '" + str(bewegung.data.get('additional_purpose', '')) + "', '" + str(bewegung.data.get('applicant_bin', '')) + "', '"
        query += str(bewegung.data.get('applicant_iban', '')) + "', '" + str(bewegung.data.get('funds_code', '')) + "', '" + str(bewegung.data.get('date', '')) + "', '"
        query += str(bewegung.data.get('return_debit_notes', '')) + "', '" + str(bewegung.data.get('status', '')) + "', '" + str(bewegung.data.get('currency', '')) + "', '"
        query += str(bewegung.data.get('posting_text', '')) + "', '" + str(bewegung.data.get('recipient_name', ''))+ "', '" + str(bewegung.data.get('bank_reference', '')) + "', '"
        query += str(amount) + "', '" + str(bewegung.data.get('entry_date', '')) + "', '" + str(bewegung.data.get('id', '')) + "');"
        cur.execute(query)
        conn.commit()
        inserted = inserted + 1
        # Mail it
        mailtext = ''
        if onlyrequested == 1:
            mailtext += "ACHTUNG: VORABBUCHUNG\n\n"
        mailtext += str(bewegung.data)
        mailtransfer(mailtext)

# Now get the Balance, and add it to every balance colum which is still empty from behind
endbalance = f.get_balance(konten[0])
p = re.compile('[-+]?\d*\.\d+|[-+]?\d+')
m = p.search(str(endbalance))
endbalance = m.group()

amountbefore = Decimal(endbalance)
bewegungsamountbefore = 0

cur = conn.cursor()
query = 'SELECT bewegid, amount FROM kontobewegungen WHERE balance = 0 AND onlyrequested = 0 ORDER BY bewegid DESC;'
cur.execute(query)
for row in cur.fetchall():
    bewegid = row[0]
    # Calculate balance
    balance = amountbefore - bewegungsamountbefore
    
    # Update the balance in the database
    cur2 = conn.cursor()
    query = 'UPDATE kontobewegungen SET balance = ' + str(balance) + ' WHERE bewegid = ' + str(bewegid) + ';'
    cur2.execute(query)
    conn.commit()
    
    # Update the fields
    bewegungsamountbefore = row[1]
    amountbefore = balance
   
print(time.strftime("%d.%m.%Y %H:%M:%S") + ': Inserted: ' + str(inserted) + '     Balance: ' + str(endbalance) + ' EUR')        
conn.close()

PS: Wie die Banken auf die Idee kommen einen Betrag so zu schicken, ist mir vollkommen unklar:

<-1.65 EUR>

Ich bin natürlich gleich mit meinem Regexp auf die Schnauze gefallen als ich mal einen Kontosaldo ohne Nachkommastellen hatte. Denn plötzlich kam nicht ‘<1234.56 EUR>’ sondern:

<-1234 EUR>

Die Nachkommastellen einfach weglassen wenn sie nicht relevant sind find ich auch eine sehr seltsame Design-Entscheidung!

Das Script läuft jetzt übrigens per Cronjob im 15-Minuten Takt. Somit bekomme ich spätestens 15 Minuten nach einer Kontobewegung eine Mail.

Achja, das FinTS Modul installiert man unter Ubuntu 16.04 wie gewohnt:

apt-get install python3 python3-pip
pip3 install fints

9 Antworten auf „Eine Datenbank mit meinen Kontobewegungen und eine Mail bei jeder Kontobewegung?“

  1. Du schreibst gerne Fire&Forgetcode oder ? Deine ganze Fehlerbehandlung fehlt, so daß wenn Dir der Code um die Ohren fliegt, und das wird er, Du es nicht mal merkst.

    Beispiel:
    cur.execute(query)
    for row in cur.fetchall():

    Wenn das Execute failed, bekommst Du es nicht mit.

    cur2.execute(query)
    conn.commit()

    Gleich zwei Dinge die typischerweise schief gehen können, denn son Datenbankserver hat auch mal ein schlechten Tag 🙂

    Was auch immer Du dann in die Datenbank schreiben wolltest, Du wirst es nie erfahren 😀

    1. Hallo Marius,

      du hast natürlich vollkommen Recht! Ich würde jetzt gerne sagen dass mein tatsächlicher Code selbstverständlich alle möglichen Fehlerbehandlungen hat, aber ehrlich gesagt: Nö!

      Getreu dem Motto: Works for me 🙂

      Sollte es tatsächlich irgendeine Ausnahme geben dann wird schon der nächste Durchlauf funktionieren. Und wenn nicht ist ja immer noch nichts verloren solange ich den Fehler innerhalb von drei Monaten bemerke kann ich ja alle Kontobewegungen nachtragen.

      Und es würde mir auffallen wenn über mehrere Monate keine Mail mit einer Kontobewegung kommt!

      Deshalb halte ich eine Fehlerbehandlung hier nicht für nötig. Das ist natürlich nicht sauber, da stimme ich dir zu!

  2. zu den transaction_codes: Das Stichwort dürfte wohl sowas wie SEPA-Purpose-Codes lauten, wahlweise auch DTA-Textschlüssel … hab aber auch den Eindruck, dass sich da die Banken auch nicht alle so ganz einig sind, ob udn wie sie das benutzen oder auch nicht… sollte man nicht meinen, dass just Banken da so intransparent sein können.

  3. Ich habe viel mit Bankdaten zu tun. Was Du suchst ist alles auf http://www.hbci-zka.de/ unter Spezifikationen beschrieben. FinTS ist die Weiterentwicklung von HBCI. Und damit können auch Überweisungen, Lastschriften usw. durchgeführt werden. Ob allerdings das Python-Modul das auch unterstützt weiß ich leider nicht.

    Mit dem Thema Datenbanknormalisierung solltest Du Dich vielleicht auch mal beschäftigen. Dein Hash ist der Primärschlüssel, bewegid fällt weg. Zudem ist Dein SHA256-Hash fester Länge, also CHAR, nicht VARCHAR. Darüber freut sich die DB-Software.

    Und noch eine schlechte Nachricht: nur ca. 50% aller deutschen Banken unterstützen HBCI/FinTS.

  4. Hey coole Sache, mir schwirrte schon länger eine ähnliche Idee rum, die mich bei vermuteten Anomalien benachrichtigt 😀 Auch online-banking für Linux werde ich mir in den kommenden Wochen doch noch einmal anschauen 🙂

  5. Erstmal Glückwunsch zu dem netten Blog, hab hier einige nette Ideen gefunden um meine eh schon knappe Freizeit noch mehr zuzuballern 🙂

    Nachdem ich auch mal etwas mit fints und deinem code rumgespielt hab ein paar Anmerkungen (hoffe dein blog unterstützt markdown):

    – Den Saldo müßte man eigentlich auch pro Buchung bekommen, jede Buchung hat nämlich neben data auch eine property transactions:
    “`
    >>> pprint(bewegung.transactions.data)
    {‘account_identification’: ‘76030080/xxxxxxxxxx’,
    ‘final_closing_balance’: < @ 2018-06-08>,
    ‘final_opening_balance’: < @ 2018-06-04>,
    ‘intermediate_closing_balance’: < @ 2018-06-04>,
    ‘intermediate_opening_balance’: < @ 2018-06-08>,
    ‘related_reference’: ‘NONREF’,
    ‘sequence_number’: ‘2’,
    ‘statement_number’: ‘0’,
    ‘transaction_reference’: ‘0’}
    “`
    final* ist wohl Anfangs- und Endsaldo für alle abgefragten Buchungen, intermediate* müßte meines Erachtens der Saldo vor und nach jeder Buchung sein, allerdings steht hier bei allen Buchungen der gleiche Wert (nämlich den der ersten Buchung), das ist m.E. wenig sinnvoll und dürfte ein Bug sein (mache dazu mal einen issue auf).

    – die API-Adressen bekommt man über http://www.hbci-zka.de/institute/institut_auswahl.htm raus (steht auch im Beispiel-Code des fints-readme, siehe https://github.com/raphaelm/python-fints/blob/master/README.md)

    – FinTS (a.k.a. HBCI 3.0) selbst kann schon noch einiges mehr als Saldo und Buchungen abfragen, nur unterstützt python-fints eben nur das. Ist mir persönlich aber auch ganz Recht so 🙂

    – Ich würde ja nicht einfach darauf vertrauen, daß dein normales Giro-Konto immer als erstes von get_sepa_accounts() geliefert wird. Besser über alle Konten iterieren und die nicht gewünschten ignorieren:
    “`python
    for konto in konten:
    if konto.accountnumber != interessante_kontonummer:
    continue
    “`
    – – Das Zusammenbauen des Strings für die Hash-Berechnung läßt sich viel einfacher und schöner machen:
    “`python
    “”.join(str(bewegung.data.get(k)) for k in (‘transaction_code’, ‘transaction_details’, ‘prima_nota’, ‘applicant_name’,…))
    “`
    Ob man da jetzt wirklich alle Felder reinnehmen sollte, finde ich auch etwas fraglich (bei den etwas obskuren könnte sich der Inhalt nach einem Update von fints oder mt940 ja schon ändern), aber das merkst du ja dann 🙂

    – Du brauchst amount nicht selbst zu parsen, es ist nämlich nicht die Bank, die das in diesem komischen Format schickt, sondern das ist einfach die string representation des Amount-Objekts (siehe https://github.com/WoLpH/mt940/blob/develop/mt940/models.py#L162), dementsprechend kannt du den Wert direkt zurückbekommen:
    “`
    >>> bewegung.data[‘amount’].amount
    Decimal(‘-100’)
    “`
    – ähnliches gilt für das Buchungsdatum, bewegung.data[‘date’] enthält ja schon ein Date-Objekt, kannst du also direkt vergleichen:
    `onlyrequested = 0 if bewegung.data[‘date’] <= date.today() else 1`

    – den Query kannst du analog zu dem hash-string von oben zusammenbauen, ABER: Du hast da keinerlei SQL escaping! Das ist also ein potentielles Einfallstor für SQL-Injection. Keine Ahnung, ob ' oder \ erlaubte Zeichen im Verwendungszweck sind, aber die können ja auch mal so in einem der anderen Felder auftauchen und dann ist der query kaputt. Also eher so:
    query += "VALUES ('" + myhash + "', " + str(onlyrequested) + ", '" + "', '".join(conn.escape_string(str(bewegung.data.get(k, ''))) for k in ('transaction_code', …)) + "', " …
    Am besten noch die keys vorher in eine Variable packen, dann wird es übersichtlicher (und wenn die Reihenfolge gleich ist wie bei der Hash-Berechnung, brauchst du die Liste nur einmal zu definieren).
    Ich persönlich bevorzuge prepared statements, da wird das quoting gleich mit gemacht und man muß keine Strings zusammenkleistern. Das könnte dann ungefähr so aussehen:
    “`python
    # vor der Schleife:
    buchung_keys = ('transaction_code', …)
    # amount ist jetzt an dritter Stelle, sonst wird es etwas umständlicher
    query_insert = 'INSERT INTO kontobewegungen (hash, onlyrequested, amount, …) …. VALUES(%s, %s, %s …)'
    # oder lieber gleich dynamisch
    query_insert = 'INSERT …. VALUES(' + ', '.join(('%s',) * (len(buchung_keys)+3)) + ')' # keys + myhash, onlyrequested, amount

    # in der Schleife dann nur:
    values = (myhash, onlyrequested, amount) + tuple(str(bewegung.data.get(k, '')) for k in buchung_keys)
    cur.execute(query_insert, values)
    “`
    – Ich hätte ja statt Mail eher die Methode aus https://www.thomaschristlieb.de/wenn-ein-tor-im-fussball-faellt-telegram-nachricht/ benutzt, aber das kann man ja einfach austauschen 🙂

    So, das reicht für's erste 🙂
    Ist etwas länger geworden, als ich dachte, aber man will ja auch etwas zurückgeben 🙂

    1. Hallo Jakob,

      doofe Ideen um die Freizeit zuzuballern gebe ich gerne.
      Freut mich wenn es gefällt und danke für das Kompliment!

      Markdown scheint es hier nicht zu geben. Komisches WordPress auch…

      Ja, den Saldo an jeder Buchung zu haben wäre eine nette Sache. Ein Issue ist eine gute Idee!
      Dann spare ich mir das manuelle ausrechnen.

      Mir ists auch recht dass python-fints nicht soviel mehr kann. Definitiv besser so, sonst könnt ich ja das Geld für dieses Blog nicht mehr bezahlen 🙂

      Mit den Verbesserungen im Programmcode und dem Abfragen der Kontonummer hast du recht. Ich hab deinen Programmcode mal so bei mir eingebaut.
      Vielen Dank!

      Dass es kein SQL Escaping gibt ist natürlich bitter. Stimmt.
      Es ist halt so dass man den Programmcode mal schnell hinrotzt bis er geht, dann baut man jede Menge Features ein und wenns dann weiterhin läuft spielt man nicht mehr dran rum.
      Erst wenns mal knallt guckt man wieder drauf (um dann festzustellen dass man den Programmcode nicht mehr kapiert).

      Zum Glück kann ich mich hier immer gut rausreden dass es ja nur Ideen sein sollen und keine fertigen Programmcodes 🙂

  6. Hallo Thomas,

    Markdown muß man in WordPress wohl explizit aktivieren (siehe https://en.support.wordpress.com/markdown/).

    intermediate_*_balance gibt es offenbar nicht bei jeder Bank, daher hab ich das mal nicht weiter verfolgt. Schlimmer finde ich allerdings, das die final_opening_balance teilweise nicht stimmt, so daß ich jetzt die final_closing_balance benutze und davon rückwärts rechne (so wie du es ja auch machst, nur etwas einfacher 🙂

    SQL-Escaping bzw. prepared statements benutze ich wirklich so gut wie immer. Wenn man sich das mal angewöhnt hat, geht das automatisch und ist i.A. auch nicht mehr Aufwand. Und man hat gute Ruhe, daß nichts passiert…

    FWIW, ich hab mir mal eine eigene Version gebaut, mit Telegramm-Anbindung und SQLite-“Backend” (wollte nicht extra mysql dafür laufen lassen). Sind noch ein paar Kleinigkeiten glattzubügeln, aber das ganze läuft bis jetzt sauber: https://github.com/jahir/kontify

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.