Ohne Moos nix los (Linux-Magazin, Februar 2025)

Kreditkarten und Debitkarten, dann noch Sparkonten und Girokonten, wer behält da noch den Überblick darüber, was so jeden Monat an Buchungen abgeht? Abos für Streaming-Services und Gebühren für Cloudspeicher, Internetanschluss undsoweiter summieren sich und wer detailliert aufschlüsselt, wo das sauer verdiente Geld hin geht, kann wichtige finanzielle Entscheidungen treffen, um Verschwendung vorzubeugen.

Seit gut 20 Jahren ist dazu die Software Ynab ("You Need A Budget") auf dem Markt (Vorläufer von "Mint"). Sie verfolgt die Buchungen auf allen Konten und Zahlungsmitteln, und spornt User dazu an, einen Haushaltsetat anzulegen und nach dem Motto "Give every Dollar a Job" jeder verdienten Währungseinheit eine Aufgabe zu geben. Dabei ist Ynab nicht auf den Dollar-Raum oder amerikanische Verhältnisse beschränkt, es lassen sich ohne weiteres Euros als Währung oder deutsche Banken als Buchungs-Importe einstellen.

Kommandozeile statt Web

Nun bietet Ynab bereits eine sehr schöne Smartphone-App und auch die Webseite reagiert super zippig, praktisch wie eine Desktop-App (Abbildung 1).

Abbildung 1: Ynab-Web-Interface Beispiel-Bankkonto mit Buchungen

Was mir persönlich noch fehlte war ein Kommandozeilen-Tool, das sich ebenfalls mit dem Ynap-API-Server verbindet und den Status Quo meiner Geldspeicher auf Tastendruck in einer Terminal-UI anzeigt (Abbildung 2). Mit den Cursor- oder Vim-Tasten (j/k) lässt sich in der linken Spalte das Konto verstellen, dessen letzte Buchungen blitzschnell in der rechten Hälfte erscheinen. Mit dem Open-Source Go-Paket ynab.go und dem Terminal-Framework termui, die der Go-Compiler flugs von Github herunterlädt, ist schnell das Go-Binary aus den Sourcen dieser Ausgabe zusammengeklopft. Aber der Reihe nach, wie gelangen nun die Finanzdaten des Users zum Ynab-Server?

Abbildung 2: Die Terminal-UI zeigt ebenfalls die Ynab-Konten an

Argusauge sei wachsam

Beim monatlichen Haushaltsetat wähle ich oft den Ansatz Pi mal Daumen, aber was auf meinen Konten vor sich geht, darüber wache ich mit Argusaugen, da gibt es keine Buchung, über die ich nicht Bescheid wüsste. Nun bietet Ynab an, die Buchungen selbst bei den Geldinstituten abzufragen und zu speichern, dazu verlangt es aber Username und Passwort des Finanzanbieters und das erscheint mir doch etwas frevelhaft. Stattdessen hole ich einmal im Monat die Buchungsdaten auf den Webseiten der Geldinstitute im CSV-Format ab, konvertiere sie in das von Ynab verlangte Format (Abbildung 3) und importiere sie über das Web-Interface in Ynab. Wen's interessiert: Auf [3] habe ich einen Konverter namens ynabler auf Github abgestellt, der die .csv-Formate mehrere großer Finanzdienstleister auf das Ynab-Format umstellt.

Abbildung 3: Eine mit ynabler konvertierte .csv-Datei eines Geldinstituts.

Anschließend bekommt jede Buchung eine Kategorie zugewiesen, abhängig davon, ob es sich um Ausgaben für den Haushalt, Spaßreisen, Geschenke, Völlerei in Gasthäusern oder Abos handelt. Das scheint nur anfangs umständlich, denn Ynab lernt mit der Zeit und weist Buchungen nach einer Weile bereits anhand des Empfängers vorher definierten Kategorien zu.

Abbildung 4: Manueller Import einer .csv-Datei in Ynab

Kategorien sind wichtig, damit Ynab später Berichte darüber erstellen kann, in welche Kanäle die Einnahmen geflossen sind. Darauf basierend darf der User im Folgemonat Budget-Beträge veranschlagen, und wie der Finanzminister persönlich die Schuldenbremse ziehen, wenn Ausgaben in einem Bereich das Budget sprengen.

Reconcile für ruhigen Schlaf

Am Ende eines Imports folgt der Druck auf den "Reconcile"-Button, und wenn der von Ynab errechnete Kontostand mit dem offiziellen des Geldinstituts übereinstimmt, werden die Buchungen bis zum letzten Datum festgezurrt. So weiß man später bei eventuell auftretenden Inkonsitenzen, bis zu welchem Zeitpunkt noch alles gestimmt hat.

Allerdings kassiert Ynab monatliche Gebühren ($14.99 im Monat oder $109 im Jahr), aber der erste Monat ist kostenfrei, wobei nicht mal eine Kreditkarte oder Zahlungsdaten zur Registrierung notwendig sind, eine Email-Adresse reicht.

Abbildung 5: Dokumentation der REST-API zu den Ynap-Daten

Automatisch per API

Damit nun ein Kommandozeilentool wie das in dieser Ausgabe vorgestellte Go-Programm ynab die Konten und deren Buchungen abfragen und anzeigen kann, muss es über die auf [2] dokumentierte REST-API Abfragen an den Ynap-API-Server schicken (Abbildung 5) und die als Json zurückkommenden Antworten aufdröseln. Als Autorisierungsmechanismus bietet Ynab für Privatentwickler, die nur ihre eigenen Konten abfragen wollen, einen API-Token, für generelle Apps eine OAuth-Schnittstelle. Für unser Go-Tool reicht der Token, und Abbildung 6 zeigt, wie ihn das Web-Interface auf app.ynab.com unter "Developer Settings" herausrückt.

Abbildung 6: Ynab rückt auf Anfrage einen API-Token heraus.

Liegt dieser Token nun einer REST-Anfrage bei, antwortet der Server mit Json-Daten wie in Abbildung 7 und der Client darf darin herumwühlen. Das Datenmodell erlaubt pro User mehrere Unterkonten, "Budgets" genannt. Otto Normalverbraucher nutzt nur ein Budget, das sowohl die Etatsplanung und alle damit verknüpften Bank- und Kreditkartenkonten umfasst. Jedes Budget enthält Konten (Accounts), und zu jedem Konto listet die API auf Wunsch dessen neuste Buchungen auf.

Abbildung 7: Json-Antwort auf eine API-Anfrage nach Buchungen

Bequemer als REST

Statt nun REST-Anfragen aufzusetzen und Json-Antworten auseinanderzufieseln, zieht Listing 1 das Paket ynab.go von Github heran, das eine komplette Go-Implementierung der Ynap-API enthält. So ruft das Programm die gesuchten Daten über Funktionen ab und findet sie in den Feldern typgerechter Strukturen.

Der vorher eingeholte Token (Abbildung 6) liegt in der Datei ~/.murmur im Home-Verzeichnis unter dem Schlüssel ynab-test vor. Die Funktion Lookup() des Murmur-Paketes holt ihn hervor und speichert ihn in Zeile 10 von Listing 1 in der Variablen apiToken. Der Token öffnet den Zugang zum damit verbundenen Konto bei Ynab.

Listing 1: ynab.go

    01 package main
    02 import (
    03   "time"
    04   "github.com/mschilli/go-murmur"
    05   "github.com/brunomvsouza/ynab.go"
    06   "github.com/brunomvsouza/ynab.go/api"
    07   "github.com/brunomvsouza/ynab.go/api/transaction"
    08 )
    09 func main() {
    10   apiToken, err := murmur.NewMurmur().Lookup("ynab-test")
    11   if err != nil {
    12     panic(err)
    13   }
    14   c := ynab.NewClient(apiToken)
    15   budgets, err := c.Budget().GetBudgets()
    16   if err != nil {
    17     panic(err)
    18   }
    19   budgetID := budgets[0].ID
    20   snp, err := c.Account().GetAccounts(budgetID, nil)
    21   if err != nil {
    22     panic(err)
    23   }
    24   since := time.Now().AddDate(0, -2, 0)
    25   txns, err := c.Transaction().GetTransactions(budgetID,
    26     &transaction.Filter{Since: &api.Date{Time: since}})
    27   if err != nil {
    28     panic(err)
    29   }
    30   runUI(snp.Accounts, txns)
    31 }

Der Aufruf der Funktion GetBudgets() in Zeile 15 findet alle im Account definierten Budgets und Zeile 19 beschränkt sich der Einfachheit halber auf das erste und wahrscheinlich einzige. Jedem Budget hat Ynab eine interne ID in Form eines Hexstrings zugewiesen. Die nachfolgenden Funktionen zum Einholen der daran hängenden Konten und deren Transaktionen schicken jeweils die Budget-ID mit, damit der Server weiß, welches Konto gemeint ist.

Nun könnte der Client zu jedem Konto des Budgets jeweils dessen Transaktionen einholen, aber das würde im vorliegenden Fall mit vier Konten vier Requests übers Netz schicken. Im Zeit zu sparen holt Zeile 25 mit GetTransactions() einfach alle Transaktionen des Budgets auf einen Rutsch ab. Jeder Json-Eintrag für Buchungen in Abbildung 7 listet ja praktischerweise die ID und sogar den Namen des zugehörigen Kontos auf, also kann der Client die Antwort hinterher leicht für die einzelnen Konten auseinanderdividieren.

Schleuse langsam öffnen

Ein jahrelang aktiv genutztes Ynab-Konto enthält unter Umständen hunderte oder tausende von Buchungen, die der Client nicht sinnvoll darstellen kann. Als Filter schaltet Zeile 26 deshalb den Parameter Since dazwischen, der einen Zeitstempel angibt, der zwei Monate in der Vergangenheit liegt. So kommen maximal die Buchungen der letzten zwei Monate zurück. Das spart nicht nur Serverzeit sondern auch verbrauchte Bandbreite während der Übertragung.

Die gefundenen Konten und all ihre Transaktionen übergibt Zeile 30 abschließend an die Funktion runUI() aus Listing 2, die die Daten zur sofortigen Darstellung aufbereitet und die UI anwirft, auf dass der User in ihr zwischen seinen Konten hin- und herfahre und die Buchungen kritisch beäuge.

Für die Curses-basierte Terminal-UI zieht Listing 2 in den Zeilen 6 und 7 das Paket termui von Github herein. Die Buchungen eines Kontos sollen später in umgekehrter zeitlicher Reihenfolge erscheinen, die neuesten Buchungen also oben im Kontofenster stehen. Den Array txns sortiert hierzu in Zeile 14 die Standardfunktion sort.Slice() mit einem Callback, der die Zeitstempel zweier Elemente mit time.After() vergleicht und true zurückgibt, falls der Zeitstempel unter Index i vor dem am Index j liegt.

Noch enthält txns die Buchungen aller Konten unter einem Budget, also macht sich die for-Schleife ab Zeile 18 daran, sie über die Map-Variable txnByID in Einzelkonten einzusortieren. Als Schlüssel dient dazu die Account-ID, als Wert der formatierte Buchungseintrag.

Listing 2: ui.go

    01 package main
    02 import (
    03   "fmt"
    04   "sort"
    05   "strings"
    06   ui "github.com/gizak/termui/v3"
    07   "github.com/gizak/termui/v3/widgets"
    08   "github.com/brunomvsouza/ynab.go/api"
    09   "github.com/brunomvsouza/ynab.go/api/account"
    10   "github.com/brunomvsouza/ynab.go/api/transaction"
    11 )
    12 const Version = "0.01"
    13 func runUI(accounts []*account.Account, txns []*transaction.Transaction) {
    14   sort.Slice(txns, func(i, j int) bool {
    15     return txns[i].Date.Time.After(txns[j].Date.Time)
    16   })
    17   txnByID := map[string][]string{}
    18   for _, txn := range txns {
    19     amStr := amtFmt(txn.Amount, 12)
    20     txnByID[txn.AccountID] = append(txnByID[txn.AccountID],
    21       fmt.Sprintf("%s %s %s", api.DateFormat(txn.Date), amStr, *txn.PayeeName))
    22   }
    23   rows := []string{}
    24   for _, account := range accounts {
    25     rows = append(rows,
    26       fmt.Sprintf("%-13s %s", account.Name, amtFmt(account.Balance, 10)))
    27   }
    28   if err := ui.Init(); err != nil {
    29     panic(err)
    30   }
    31   defer ui.Close()
    32   //
    33   lb := widgets.NewList()
    34   lb.Rows = rows
    35   lb.SelectedRow = 0
    36   lb.SelectedRowStyle = ui.NewStyle(ui.ColorBlack)
    37   lb.TextStyle.Fg = ui.ColorGreen
    38   lb.Title = fmt.Sprintf("ynab " + Version)
    39   //
    40   detail := widgets.NewParagraph()
    41   //
    42   pa := widgets.NewParagraph()
    43   pa.Text = "[Q]uit"
    44   pa.TextStyle.Fg = ui.ColorBlack
    45   //
    46   w, h := ui.TerminalDimensions()
    47   split := w / 3
    48   lb.SetRect(0, 0, split, h-3)
    49   detail.SetRect(split+1, 0, w, h-3)
    50   pa.SetRect(0, h-3, w, h)
    51   detail.Text = strings.Join(fmtDetails(accounts[lb.SelectedRow], txnByID), "\n")
    52   ui.Render(lb, pa, detail)
    53   uiEvents := ui.PollEvents()
    54   for {
    55     select {
    56     case e := <-uiEvents:
    57       switch e.ID {
    58       case "k", "<Up>":
    59         lb.ScrollUp()
    60       case "j", "<Down>":
    61         lb.ScrollDown()
    62       case "q", "<C-c>":
    63         return
    64       }
    65       detail.Text = strings.Join(fmtDetails(accounts[lb.SelectedRow], txnByID), "\n")
    66       ui.Render(lb, detail)
    67     }
    68   }
    69 }

Auf den Schirm!

Nun geht es an die grafische Darstellung im Terminal-Fenster. Listing 2 initialisiert hierzu in Zeile 28 die Terminal-UI mit ui.Init(). Damit das Terminal und die darin laufende Shell auch nach einem Programmabruch wieder in den "Cooked"-Modus schalten, stellt Zeile 31 mit defer sicher, dass das Hauptprogramm, egal wie es endet, kurz vor dem Abnippeln noch ui.Close() die UI aufräumt und den "Raw"-Modus der Terminal-UI abschaltet.

Zeile 33 definiert das linksseitige Konten-Navigations-Menü als Listbox. Deren Zeilen liegen als Array im Feld Rows und je nachdem, auf welchen Eintrag der User mit den Cursortasten fährt, steht SelectedRow auf einer von 0 an aufsteigenden Indexnummer. Die wird der Event-Handler später nutzen, um in den Konto-Array hineinzufassen um, um bei Bedarf das rechts stehende Detailfenster zum aktuellen Konto aufzufrischen.

Das Detailfenster für die Buchungen ist ein Widget vom Typ Paragraph, dessen angezeigte Zeilen in der String-Variablen Text stehen. Der Balken am unteren Bildrand, der mit [Q]uit anzeigt, dass der User das Programm mit einem Druck auf die Q-Taste verlassen kann, kommt ebenfalls als Paragraph-Widget daher.

Abbildung 8: Layout des TermUI-Fensters

Das termui-Paket beherrscht kein dynamisches Layout, vielmehr gibt der Code die Koordinaten der dargestellten Rechtecke fest vor. Die Funktion TerminalDimensions() frägt hierzu die Breite und Höhe des Terminal-Fensters ab und aus dem Layout in Abbildung 8 ergeben sich durch Arithmetik die Begrenzungen der drei Schaltflächen. Die Funktion SetRect() eines Widgets nimmt hierzu vier Koordinaten entgegen, die X/Y-Position der linken oberen Ecke des beanspruchten Rechtecks, sowie dessen Breite und Höhe (positiv nach unten).

Heda! Wohin des Wegs?

Auf den Schirm bringt das Ganze der Aufruf von ui.Render() in Zeile 52 mit allen drei Widgets als Parameter. Damit die UI auf User-Eingaben reagieren kann, startet Zeile 53 mit PollEvents() eine Event-Schleife im Hintergrund. Ereignet sich etwas, wie dass der User eine Taste drückt, bringt der zurückgelieferte Channel uiEvents das Ereignis hoch.

Die endlose for-Schleife ab Zeile 54 lauert über eine select-Anweisung an der Ereignisquelle. Tippt der User "K" soll der Cursor hoch in der Listbox, bei "J" runter, ganz wie im Vim-Editor. Entsprechend frischen ScrollUp() und ScrollDown() die Listbox auf. In beiden Fällen muss sich der Inhalt des rechts liegenden Detailfensters mit den Buchungsdaten des neu gewählten Kontos auffrischen, also setzt Zeile 65 dessen Text-Feld auf die bereits vorformatierten Zeilen aus dem Map-Eintrag des per ID referenzierten Kontos. Ohne ui.Render() in Zeile 66 würden Konto- und Detail-Widget zwar intern aufgefrischt, aber nicht neu gemalt, also ist der Aufruf essentiell, damit ein Ruck durch die App geht.

Lokale Gepflogenheiten

Andere Länder, andere Sitten, das stimmt auch für angezeigte Geldbeträge. Das lokal bevorzugte Format bezieht sich nicht nur auf die Währung, ob Dollar oder Euro, sondern auch darauf, wie Fließkommas oder die Gruppierung von Tausendern aussehen. Ein europäischer Geldbetrag von 1.234,56 € würde in Amerika als $1,234.56 erscheinen. Drei Unterschiede stechen ins Auge: Der Dollar steht ohne Leerzeichen vor den Ziffern, während das Euro-Zeichen nach einem Leerzeichen hinter dem Betrag steht. Das Zeichen für ein Fließkomma, das die Centbeträge abtrennt, ist in Amerika ein Punkt und in Europa ein Komma. Und schließlich die Gruppierung der Tausender: Ein Punkt separiert die Grüppchen in Europa, ein Komma die in Amerika.

Listing 3: util.go

    01 package main
    02 import (
    03   "fmt"
    04   "golang.org/x/text/language"
    05   "golang.org/x/text/message"
    06   "github.com/brunomvsouza/ynab.go/api/account"
    07 )
    08 func amtFmt(amount int64, wide int) string {
    09   amt := float64(amount) / 1000
    10   color := "(fg:green)"
    11   sign := ""
    12   if amt < 0 {
    13     sign = "-"
    14     amt = -amt
    15     color = "(fg:red)"
    16   }
    17   p := message.NewPrinter(language.English)
    18   amStr := p.Sprintf("%s$%.2f", sign, amt)
    19   return fmt.Sprintf("[%*s]%s", wide, amStr, color)
    20 }
    21 func fmtDetails(account *account.Account, txnByID map[string][]string) []string {
    22   details := []string{
    23     fmt.Sprintf("%-11s%s", "Balance", amtFmt(account.Balance, 12)),
    24   }
    25   for _, e := range txnByID[account.ID] {
    26     details = append(details, e)
    27   }
    28   return details
    29 }

Listing 3 definiert ab Zeile 8 die Funktion amtFmt() ("Amount Formatter"), damit alle gezeigten Beträge einheitlich aussehen. Der Zahlenformatierer aus der Standardbibliothek wird in Zeile 17 auf die englische Formatierung eingenordert, wer das deutsche Format bevorzugt, sollte language.German einstellen. Die voranstehende Logik setzt ein Dollarzeichen vor den Betrag und als Termui-Spezialität zeigt [x](fg:red) das x in einem roten Terminal-Font an und fg:green entsprechend Grün, damit negative und positive Beträge sofort ins Auge stechen.

Listing 4: build.sh

    1 $ go mod init ynab
    2 $ go mod tidy
    3 $ go build ynab.go ui.go util.go

Die drei Listings dieser Ausgabe schraubt wie immer der Dreisatz aus Listing 4 zu einem Binary zusammen, samt aller von Github eingeholten Abhängigkeiten. Dann ist der API-Key vom Ynab-Konto abzuholen und in ~/.murmur abzulegen, in Listing 1 wurde hierzu der Schlüssel ynab-test verwendet.

Sofort nach dem Programmstart holt der Code binnen einer Sekunde die Buchungsdaten vom Ynab-Server und stellt sie dann ohne weitere Verzögerung dar. Wechselt der User später die Ansicht, zum Beispiel durch Auswahl eines anderen Kontos, ist kein Nachladen mehr erforderlich, da alle Konten bereits im Speicher liegen. Das Ganze lässt sich freilich noch erweitern. Die API erlaubt nicht nur das Lesen der Daten, sondern lässt User unter anderem auch Buchungen einfügen, sodass sich zum Beispiel der manuelle Importprozess ebenfalls automatisieren ließe.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2025/01/snapshot/

[2]

Ynab-API Dokumentation: https://api.ynab.com/v1

[3]

CSV-Konvertierer ynabler: https://github.com/mschilli/go-ynabler

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc