Aus Linux-Magazin 02/2025

Finanzen verwalten mit YNAB und Go-UI

© Wutthichai Luemuang / 123RF.com

Um seine Finanzen im Auge zu behalten, nutzt Mike Schilli das Tool YNAB. Bislang fehlte eine in Go geschriebene Terminal-UI, die er hier nachliefert.

Kredit- und Debitkarten, Spar- und Girokonten – wer behält da noch den Überblick darüber, was so jeden Monat an Buchungen vor sich geht? Abos für Streaming-Services, Gebühren für Cloud-Speicher, der Internetanschluss und so weiter summieren sich. Wer detailliert aufschlüsselt, wo das sauer verdiente Geld hingeht, kann wichtige finanzielle Entscheidungen treffen, um möglicher Verschwendung vorzubeugen.

Seit gut 20 Jahren gibt es dazu die Software YNAB (You Need A Budget) auf dem Markt, den Vorläufer von Intuit Mint. Sie verfolgt die Buchungen auf allen Konten und Zahlungsmitteln und spornt den Anwender 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, der Nutzer darf Euro als Währung und deutsche Banken als Buchungsimporte einstellen.

CLI statt Web

Nun bietet YNAB bereits eine schöne Smartphone-App, und auch die Webseite reagiert superflott, praktisch wie eine Desktop-App (Abbildung 1). Was mir persönlich noch fehlte, war ein Kommandozeilenwerkzeug, das sich desgleichen mit dem YNAB-API-Server verbindet und den Status quo meiner Geldspeicher auf Tastendruck in einer Terminal-UI anzeigt (Abbildung 2).

Abbildung 1: Das YNAB-Webinterface zeigt ein Beispiel-Bankkonto mit Buchungen.

Abbildung 1: Das YNAB-Webinterface zeigt ein Beispiel-Bankkonto mit Buchungen.

Abbildung 2: Die Terminal-UI zeigt ebenfalls die YNAB-Konten des Budgets an.

Abbildung 2: Die Terminal-UI zeigt ebenfalls die YNAB-Konten des Budgets an.

Mit den Pfeil- oder Vim-Tasten ([J]+/[K]) lässt sich in der linken Spalte das Konto umstellen, dessen letzte Buchungen dann 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?

Holzauge sei wachsam

Beim monatlichen Haushaltsetat wähle ich oft den Ansatz Pi mal Daumen. Was aber 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 Nutzername 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 Webinterface in YNAB. Wen es interessiert: Auf Github habe ich einen Konverter namens Ynabler [1] abgestellt, der die CSV-Formate mehrerer großer Finanzdienstleister auf das YNAB-Format umstellt.

Abbildung 3: Eine mit Ynabler konvertierte CSV-Datei eines Geldinstituts.

Abbildung 3: Eine mit Ynabler konvertierte CSV-Datei eines Geldinstituts.

Anschließend weise ich jeder Buchung eine Kategorie zu, abhängig davon, ob es sich um Ausgaben für den Haushalt, Reisen und Geschenke, um Völlerei in Gasthäusern oder um 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 den vorher definierten Kategorien zu.

Abbildung 4: Manueller Import einer CSV-Datei in YNAB.

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 Benutzer im Folgemonat Budgetbeträge veranschlagen und wie der Finanzminister persönlich die Schuldenbremse ziehen, wenn Ausgaben in einem Bereich das Budget sprengen.

Am Ende eines Imports folgt der Druck auf den Reconcile-Button. Stimmt der von YNAB errechnete Kontostand mit dem offiziellen des Geldinstituts überein, gelten die Buchungen bis zum letzten Datum als festgezurrt. So lässt sich später bei eventuell auftretenden Inkonsistenzen feststellen, bis zu welchem Zeitpunkt noch alles korrekt war.

Allerdings kassiert YNAB monatliche Gebühren (15 US-Dollar im Monat respektive 109 US-Dollar im Jahr). Immerhin bleibt der erste Monat kostenfrei. Man benötigt zur Registrierung weder eine Kreditkarte noch Zahlungsdaten, eine E-Mail-Adresse genügt.

Abbildung 5: Dokumentation der REST-API zu den YNAB-Daten.

Abbildung 5: Dokumentation der REST-API zu den YNAB-Daten.

Automatisch per API

Damit nun ein Kommandozeilenwerkzeug wie das hier vorgestellte Go-Programm »ynab« die Konten und deren Buchungen abfragen und anzeigen kann, muss es die in der YNAB-API-Dokumentation [2] beschriebene REST-API-Abfragen an den YNAB-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, ein API-Token, für generelle Apps eine OAuth-Schnittstelle. Für unser Go-Tool genügt der Token. Abbildung 6 zeigt, wie ihn das Webinterface auf http://app.ynab.com unter Developer Settings herausrückt.

Abbildung 6: YNAB rückt auf Anfrage ein API-Token heraus.

Abbildung 6: YNAB rückt auf Anfrage ein API-Token heraus.

Liegt dieses Token nun einer REST-Anfrage bei, antwortet der Server mit JSON-Daten (Abbildung 7), und der Client darf darin herumwühlen. Das Datenmodell erlaubt pro Benutzer mehrere Unterkonten, Budgets genannt. Otto Normalverbraucher nutzt nur ein Budget, das sowohl die Etatplanung 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 neueste Buchungen auf.

Abbildung 7: JSON-Antwort auf eine API-Anfrage nach Buchungen.

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 YNAB-API enthält. Damit ruft das Programm die gesuchten Daten über Funktionen ab und findet sie in den Feldern typgerechter Strukturen.

Das vorher eingeholte Token liegt in der Datei »~/.murmur« im Home-Verzeichnis unter dem Schlüssel »ynab-test« vor. Die Funktion »Lookup()« aus dem Paket murmur holt es hervor und speichert es in Zeile 10 in der Variablen »apiToken«. Das Token öffnet den Zugang zum damit verbundenen Konto bei YNAB.

Der Aufruf der Funktion »GetBudgets()« in Zeile 15 findet alle im Account definierten Budgets, Zeile 19 beschränkt sich der Einfachheit halber auf das erste und wahrscheinlich einzige. Jedem Budget hat YNAB eine interne ID in Form eines Hex-Strings 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. Um Zeit zu sparen, holt Zeile 25 mit »GetTransactions()« einfach alle Transaktionen des Budgets in einem Rutsch ab. In Abbildung 7 listet ja jeder JSON-Eintrag für Buchungen praktischerweise die ID und sogar den Namen des zugehörigen Kontos auf. So kann der Client die Antwort hinterher leicht für die einzelnen Konten auseinanderdividieren.

Listing 1

ynab.go

package main
import (
 "time"
 "github.com/mschilli/go-murmur"
 "github.com/brunomvsouza/ynab.go"
 "github.com/brunomvsouza/ynab.go/api"
 "github.com/brunomvsouza/ynab.go/api/transaction"
)
func main() {
 apiToken, err := murmur.NewMurmur().Lookup("ynab-test")
 if err != nil {
 panic(err)
 }
 c := ynab.NewClient(apiToken)
 budgets, err := c.Budget().GetBudgets()
 if err != nil {
 panic(err)
 }
 budgetID := budgets[0].ID
 snp, err := c.Account().GetAccounts(budgetID, nil)
 if err != nil {
 panic(err)
 }
 since := time.Now().AddDate(0, -2, 0)
 txns, err := c.Transaction().GetTransactions(budgetID,
 &transaction.Filter{Since: &api.Date{Time: since}})
 if err != nil {
 panic(err)
 }
 runUI(snp.Accounts, txns)
}

Schleuse langsam öffnen

Ein jahrelang aktiv genutztes YNAB-Konto enthält unter Umständen Hunderte oder Tausende Buchungen, die der Client nicht sinnvoll darzustellen vermag. Als Filter schaltet Zeile 26 deshalb den Parameter »Since« dazwischen. Er gibt einen Zeitstempel an, der zwei Monate in der Vergangenheit liegt. So kommen maximal die Buchungen der letzten zwei Monate zurück. Das spart nicht nur Serverzeit, sondern zusätzlich Bandbreite.

Die gefundenen Konten und all ihre Transaktionen übergibt Zeile 30 abschließend an die Funktion »runUI()« aus Listing 2, die alle Daten zur sofortigen Darstellung aufbereitet. Außerdem wirft sie die UI an, auf dass der User darin zwischen seinen Konten hin und her fahre 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. Die Standardfunktion »sort.Slice()« sortiert dazu das Array »txns«u in Zeile 1> mit einem Callback, der die Zeitstempel zweier Elemente mit »time.After()« vergleicht. Er gibt »true« zurück, falls der Zeitstempel unter dem Index »i« vor dem unter dem Index »j« liegt.

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

Listing 2

ui.go

package main
import (
  "fmt"
  "sort"
  "strings"
  ui "github.com/gizak/termui/v3"
  "github.com/gizak/termui/v3/widgets"
  "github.com/brunomvsouza/ynab.go/api"
  "github.com/brunomvsouza/ynab.go/api/account"
  "github.com/brunomvsouza/ynab.go/api/transaction"
)
const Version = "0.01"
func runUI(accounts []*account.Account, txns []*transaction.Transaction) {
  sort.Slice(txns, func(i, j int) bool {
    return txns[i].Date.Time.After(txns[j].Date.Time)
  })
  txnByID := map[string][]string{}
  for _, txn := range txns {
    amStr := amtFmt(txn.Amount, 12)
    txnByID[txn.AccountID] = append(txnByID[txn.AccountID],
      fmt.Sprintf("%s %s %s", api.DateFormat(txn.Date), amStr, *txn.PayeeName))
  }
  rows := []string{}
  for _, account := range accounts {
    rows = append(rows,
      fmt.Sprintf("%-13s %s", account.Name, amtFmt(account.Balance, 10)))
  }
  if err := ui.Init(); err != nil {
    panic(err)
  }
  defer ui.Close()
  //
  lb := widgets.NewList()
  lb.Rows = rows
  lb.SelectedRow = 0
  lb.SelectedRowStyle = ui.NewStyle(ui.ColorBlack)
  lb.TextStyle.Fg = ui.ColorGreen
  lb.Title = fmt.Sprintf("ynab " + Version)
  //
  detail := widgets.NewParagraph()
  //
  pa := widgets.NewParagraph()
  pa.Text = "[Q]uit"
  pa.TextStyle.Fg = ui.ColorBlack
  //
  w, h := ui.TerminalDimensions()
  split := w / 3
  lb.SetRect(0, 0, split, h-3)
  detail.SetRect(split+1, 0, w, h-3)
  pa.SetRect(0, h-3, w, h)
  detail.Text = strings.Join(fmtDetails(accounts[lb.SelectedRow], txnByID), "\n")
  ui.Render(lb, pa, detail)
  uiEvents := ui.PollEvents()
  for {
    select {
    case e := <-uiEvents:
      switch e.ID {
      case "k", "<Up>":
        lb.ScrollUp()
      case "j", "<Down>":
        lb.ScrollDown()
      case "q", "<C-c>":
        return
      }
      detail.Text = strings.Join(fmtDetails(accounts[lb.SelectedRow], txnByID), "\n")
      ui.Render(lb, detail)
    }
  }
}

Auf den Schirm!

Nun geht es an die grafische Darstellung im Terminal-Fenster. Listing 2 initialisiert dazu in Zeile 28 die Terminal-UI mit »ui.Init()«. Das Terminal und die darin laufende Shell sollen auch nach einem Programmabbruch wieder in den Cooked-Modus schalten. Deshalb 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 Kontennavigationsmenü als Listbox, deren Zeilen als Array im Feld »Rows« liegen. Je nachdem, auf welchen Eintrag der User mit den Pfeiltasten fährt, steht »SelectedRow« auf einer von 0 an aufsteigenden Indexnummer. Sie nutzt der Event Handler später, um in das Konto-Array hineinzufassen und 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 [Q] verlassen kann, kommt ebenfalls als »Paragraph«-Widget daher.

Abbildung 8: Layout des Termui-Fensters.

Abbildung 8: Layout des Termui-Fensters.

Das Paket termui beherrscht kein dynamisches Layout. Stattdessen gibt der Code die Koordinaten der dargestellten Rechtecke fest vor. Die Funktion »TerminalDimensions()« fragt dazu die Breite und Höhe des Terminalfensters ab. Aus dem Layout in Abbildung 8 ergeben sich durch Arithmetik die Begrenzungen der drei Schaltflächen. Die Funktion »SetRect()« eines Widgets nimmt dazu vier Koordinaten entgegen: die X/Y-Position der linken oberen Ecke des beanspruchten Rechtecks sowie dessen Breite und Höhe (positiv nach unten).

Wohin des Wegs?

Der Aufruf von »ui.Render()« in Zeile 52 mit allen drei Widgets als Parameter bringt das Gesamtkunstwerk auf den Schirm. Damit die Nutzerschnittstelle auf Eingaben reagieren kann, startet Zeile 53 mit »PollEvents()« eine Event-Schleife im Hintergrund. Ereignet sich etwas, wie ein Tastendruck des Nutzers, bringt der zurückgelieferte Channel »uiEvents« das Ereignis hoch.

Die endlose For-Schleife ab Zeile 54 wacht über eine »select«-Anweisung an der Ereignisquelle. Tippt der User [K]+, bewegt sich der Cursor in der Listbox nach oben, bei [J] nach unten, ganz wie im Editor Vim. Entsprechend aktualisieren »ScrollUp()« und »ScrollDown()« die Listbox. 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. Deshalb ist der Aufruf essenziell, damit ein Ruck durch die App geht.

Lokale Gepflogenheiten

Andere Länder, andere Sitten – das gilt selbst 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 »12.345,67 ¤« würde in den USA als »$12,345.67« erscheinen.

Drei Unterschiede stechen ins Auge: Der Dollar steht ohne Leerzeichen vor den Ziffern, das Euro-Zeichen nach einem Leerzeichen hinter dem Betrag. Als Zeichen für ein Fließkomma, das die Cent-Beträge abtrennt, fungiert in Nordamerika ein Punkt und in Europa ein Komma. Bei der Gruppierung der Tausender separiert hierzulande ein Punkt die Grüppchen, jenseits des großen Teichs übernimmt das ein Komma.

Listing 3 definiert ab Zeile 8 die Funktion »amtFmt()« (Amount Formatter) aus der Standardbibliothek, damit alle gezeigten Beträge einheitlich aussehen. Zeile 17 nordet den Zahlenformatierer auf die englische Formatierung ein. Wer das deutsche Format nutzt, stellt stattdessen »language.German« ein. Die voranstehende Logik setzt ein Dollarzeichen vor den Betrag. Als Termui-Spezialität zeigt »[x](fg:red)« das »x« in einem roten Font an, »fg:green« sorgt entsprechend für grüne Ziffern. Damit stechen negative und positive Beträge sofort ins Auge.

Listing 3

util.go

package main
import (
  "fmt"
  "golang.org/x/text/language"
  "golang.org/x/text/message"
  "github.com/brunomvsouza/ynab.go/api/account"
)
func amtFmt(amount int64, wide int) string {
  amt := float64(amount) / 1000
  color := "(fg:green)"
  sign := ""
  if amt < 0 {
    sign = "-"
    amt = -amt
    color = "(fg:red)"
  }
  p := message.NewPrinter(language.English)
  amStr := p.Sprintf("%s$%.2f", sign, amt)
  return fmt.Sprintf("[%*s]%s", wide, amStr, color)
}
func fmtDetails(account *account.Account, txnByID map[string][]string) []string {
  details := []string{
    fmt.Sprintf("%-11s%s", "Balance", amtFmt(account.Balance, 12)),
  }
  for _, e := range txnByID[account.ID] {
    details = append(details, e)
  }
  return details
}

Endspurt

Die drei Listings dieser Ausgabe schraubt wie immer der Dreisatz aus Listing 4 zu einem Binary zusammen, samt aller von Github eingeholten Abhängigkeiten. Nun gilt es noch, den API-Key vom YNAB-Konto abzuholen und in »~/.murmur« abzulegen. In Listing 1 haben wir dazu den Schlüssel »ynab-test« verwendet.

Listing 4

build.sh

$ go mod init ynab
$ go mod tidy
$ go build ynab.go ui.go util.go

Sofort nach dem Programmstart holt der Code binnen einer Sekunde die Buchungsdaten vom YNAB-Server und stellt sie ohne weitere Verzögerung dar. Wechselt der Anwender später die Ansicht, zum Beispiel durch Auswahl eines anderen Kontos, ist kein Nachladen mehr erforderlich: Alle Konten liegen bereits im Speicher. Das Ganze lässt sich freilich noch erweitern. Die API erlaubt nicht nur das Lesen der Daten, sondern ermöglicht unter anderem auch Buchungen, sodass sich der eingangs beschriebene manuelle Importprozess darüber gleichfalls automatisieren ließe. (uba)

Infos

  1. CSV-Konvertierer Ynabler: https://github.com/mschilli/go-ynabler
  2. YNAB-API-Dokumentation: https://api.ynab.com/v1
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 8 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben