Author SHA1 Message Date
GuidoandClaude Opus 5 09ec2dc5fd feat: Bildschirmsperre mit Face ID und PIN
Beim ersten Start fuehrt kein Weg an einer sechsstelligen PIN vorbei. Danach
sperrt die App beim Kaltstart und beim Wegschalten; kommt man binnen einer
Minute zurueck, geht sie ohne Nachfrage wieder auf. Face ID zuerst, die PIN
als Rueckfallweg und als einziger Weg ohne Biometrie. Nach fuenf falschen
PINs meldet die App ab.

Gesperrt wird schon bei inactive, nicht erst im Hintergrund - sonst stuenden
die Kontostaende im App-Umschalter.

Von der PIN liegt nur ein gesalzener SHA-256 im Schluesselbund. Die
Gerätemarke selbst bleibt wie bisher abgelegt: Die Sperre verdeckt die
Oberflaeche, sie verschluesselt nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 18:09:05 +02:00
GuidoandClaude Opus 5 4e7f572d93 chore: Appicon mit eigenem Dunkelmodus, Projekt auf iPhone-only
Aus Xcode uebernommen: eigener Verlauf fuer den Dunkelmodus im Icon,
Kategorie Finanzen, nur iPhone, Hochformat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:59:20 +02:00
GuidoandClaude Opus 5 2d1d571b17 feat: Gruen ist die Vorgabe fuer Kategorien und Schlagwoerter
Die Farbauswahl der App lag dreimal gleich im Code; sie steht jetzt einmal
in Farbwahl, mit dem Markengruen an erster Stelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:51:49 +02:00
GuidoandClaude Opus 5 a079bf406c feat: Die iOS-App traegt die Markenfarbe statt des System-Blaus
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:45:54 +02:00
GuidoandClaude Opus 5 929a735699 feat: Appicon der iOS-App - Icon-Composer-Buendel eingehaengt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:40:06 +02:00
GuidoandClaude Opus 5 9195906c2b chore: Marke als freigestelltes SVG statt Appicon-Vorlage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:36:51 +02:00
GuidoandClaude Opus 5 f918297742 chore: Appicon-Vorlage als SVG neben der iOS-App
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:33:52 +02:00
GuidoandClaude Opus 5 37517f360b docs: Uebergabe, README und Fahrplan halten Stufe 5 fest
Neu drin: warum der zweite Faktor sich hier einrichten, aber nicht
abschalten laesst (das verlangt das Kontopasswort, und die App speichert
keines - ein Vollzugang auf dem Geraet soll den Schutz nicht aufheben
koennen), warum auf dem Telefon die otpauth-Adresse statt eines QR-Codes
genommen wird, und dass die Rettungscodes genau einmal erscheinen.

Dazu der Grund fuer Einstellungen ohne Sichern-Knopf und der Hinweis, dass
sie dem Konto gehoeren, nicht dem Geraet.

Die Liste dessen, was bewusst am Rechner bleibt, ist jetzt vollstaendig:
Vertragslogo, Kontoreihenfolge, Loesen einer Verknuepfung,
Transfer-Schlagwoerter und das Anwenden von Regeln auf Bestehendes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 22:04:35 +02:00
GuidoandClaude Opus 5 d290930759 feat: Fuenfte Stufe der iOS-App - Kategorien, Schlagwoerter, Bank, zweiter Faktor
Die Nebenraeume haengen unter Einstellungen, nicht als eigene Reiter: Man
braucht sie selten, und eine Leiste mit zehn Reitern findet niemand mehr.

- Kategorien: anlegen, aendern, archivieren, loeschen - samt Gruppen. Eine
  Kategorie oeffnet ihre Erkennungswoerter, die sich dort hinzufuegen und
  entfernen lassen. Darunter steht, was sonst niemand merkt: Sie wirken nur
  auf kuenftige Importe.
- Woerter, die oft korrigiert werden, stehen als Pruefmeldung in den
  Einstellungen - behalten oder entfernen.
- Schlagwoerter: anlegen, umbenennen, umfaerben, loeschen. Neue bekommen
  reihum eine Farbe, damit nicht alle grau sind.
- Einstellungen: Erscheinungsbild, Hauptwaehrung und Budget-Starttag lassen
  sich jetzt aendern. Ohne Sichern-Knopf - es ist je Zeile genau ein Wert,
  und das Backend nimmt ihn einzeln entgegen. Dazu der Hinweis, dass sie dem
  Konto gehoeren und nicht dem Geraet.
- Bank: abrufen und Salden auffrischen. Verbinden bleibt am Rechner, die
  Bank leitet fest dorthin zurueck. Der letzte Fehler der Verbindung steht
  dabei, sonst ruft man vergeblich ab.
- Zweiter Faktor: einrichten in drei Schritten. Auf dem Telefon fuehrt der
  Weg ueber die otpauth-Adresse direkt in die Authenticator-App statt ueber
  einen QR-Code, den man sich selbst zeigen muesste. Die Rettungscodes
  erscheinen einmal; das Blatt laesst sich nicht wegwischen und erst
  schliessen, wenn man bestaetigt hat, sie gesichert zu haben.

Abschalten des zweiten Faktors bleibt bewusst draussen: Es verlangt das
Kontopasswort, und die App speichert keines. Ein Vollzugang auf diesem Geraet
soll den Schutz des Kontos nicht aufheben koennen.

59 Tests gruen. Texte in Deutsch und Englisch, zeilengleich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 22:03:28 +02:00
GuidoandClaude Opus 5 bab5cc5060 test: Die Pruefdatei deckt auch die Nebenraeume ab
Erkennungswoerter einer Kategorie, markierte Woerter, der Stand des zweiten
Faktors und die Bankverbindungen kommen jetzt mit in
ios/Tests/Fixtures/antworten.json. Das Skript gibt die IDs aus der Saat
zurueck, damit ein Pfad wie /categories/{id}/keywords ueberhaupt gebaut
werden kann.

Zwei davon bleiben leer, und das steht auch dort: Eine Markierung entsteht
erst nach fuenf Anwendungen mit Korrekturen, eine Bankanbindung ohne echten
Anbieter gar nicht. Die Felder von BankConnectionOut deckt der Test also
NICHT ab - wer sie aendert, prueft von Hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 22:03:28 +02:00
GuidoandClaude Opus 5 6c6257b8f9 docs: Uebergabe, README und Fahrplan halten Stufe 4 fest
Neu drin: wo der einzige 409-Fall sitzt und wie er behandelt wird (eine
Frage, kein Fehler), woran man einen Uebertrag im Eingang erkennt, warum die
Dublettenliste die Kennung der Bank zeigt, und der Backend-Fehler, den die
App ans Licht brachte.

Ausserdem der Hinweis, der den naechsten sonst eine Stunde kostet: Die
Pruefdatei-Tests zaehlen keine Mengen mehr. Wer die Saat erweitert, macht
sonst die aelteren Pruefungen rot, ohne dass etwas kaputt waere.

Festgehalten, was weiter bewusst fehlt: Vertragslogo, Kontoreihenfolge und
das Loesen einer Uebertrags-Verknuepfung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:29:10 +02:00
GuidoandClaude Opus 5 9edf610d59 feat: Vierte Stufe der iOS-App - die Arbeitsliste
Ein neuer Reiter "Eingang" mit allem, worueber jemand entscheiden muss. Vier
Abschnitte, jeweils mit Anzahl im Kopf:

- Zu buchen: offene Eingangseintraege aus Kurzbefehl, Postfach oder Bank.
  Buchen, als Uebertrag buchen, verwerfen - oder alle auf einmal, bei denen
  das Konto feststeht. Unklare tragen einen Hinweis ueber den Knoepfen, nicht
  darunter: Man soll sie ansehen, nicht wegbuchen.
- Koennten Uebertraege sein: zwei getrennte Buchungen, die zusammengehoeren
  koennten. Verbinden oder ablehnen. Hat eine der beiden eine Kategorie,
  steht dabei, dass sie durchs Verbinden aus dem Budget faellt.
- Ohne Kategorie: gebuchtes Geld, das in keiner Budget-Zeile mitzaehlt. Ein
  Tipp, eine Kategorie - kein Formular mit zwoelf Feldern davor.
- Sieht doppelt aus: gleiches Konto, gleicher Tag, gleicher Betrag. Mit der
  Kennung der Bank je Zeile, denn verschiedene Kennungen heissen, dass die
  Bank sie als verschiedene Zahlungen gemeldet hat.

Alles in einem Abruf. Vier getrennte Zwischenspeicher koennten offline aus
vier verschiedenen Momenten stammen und sich widersprechen.

Hier sitzt der einzige 409-Fall der App: Beim Buchen als Uebertrag kann das
Backend Bedenken haben. Das ist eine Frage, kein Fehler - die App zeigt den
Hinweis und wiederholt den Aufruf auf Wunsch mit trotzdem=true. Ihn zu
verschlucken waere falsch, ihn als blossen Fehler zu zeigen auch.

Zwei Dinge, die die Tests festhalten: Ohne Konto laesst sich nichts buchen
(das Backend lehnt mit 400 ab, der Knopf bleibt also grau - und die Zeile sagt
auch warum). Und ein Uebertrag braucht keine Kategorie, sonst fragte die Liste
ewig nach einer, die es nie geben wird.

Die Pruefdatei deckt jetzt auch Eingang, Dubletten und Uebertragsvorschlaege
ab. Die aelteren Pruefungen zaehlten exakt und wurden dadurch rot - sie
pruefen jetzt Eigenschaften statt Mengen, damit sie das naechste Wachstum
ueberleben. 54 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:28:00 +02:00
GuidoandClaude Opus 5 adbc3e0aa4 fix: Ein Uebertragsvorschlag erscheint nur noch einmal
Jedes Paar steht in der Kandidatenliste zweimal, einmal je Richtung. Was
verknuepft wird, faellt ueber "verbraucht" heraus - ein blosser Vorschlag aber
nicht. Also bot /uebertraege/vorschlaege dieselbe Zahlung zweimal zum
Verbinden an, und wer das zweite anklickte, bekam "bereits verknuepft" fuer
etwas, das er gerade selbst verknuepft hatte.

Betrifft auch die Weboberflaeche: Uebertragsvorschlaege.jsx zeigt die Liste
unveraendert an und hatte denselben Doppeleintrag.

Gefunden beim Bauen der Arbeitsliste fuer die iOS-App - die Pruefdatei brachte
den Vorschlag doppelt zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:28:00 +02:00
GuidoandClaude Opus 5 d04b6b74ad docs: Uebergabe, README und Fahrplan halten Stufe 3 fest
Neu drin: die vier Regeln, die man nicht sieht, aber merkt, wenn sie fehlen
(Teilaenderung schickt nur Gesetztes, leere Tag-Liste bedeutet etwas, kein
Kategorie beim Uebertrag, planned_cents bleibt bei "berechnet" unberuehrt),
und warum die Waehrung eines bestehenden Kontos nicht aenderbar ist.

Dazu der Befehl, mit dem man fehlende Uebersetzungen findet - ein unbekannter
Schluessel uebersetzt anstandslos und zeigt dem Nutzer den Schluessel.

Festgehalten, was aus Stufe 3 bewusst nicht mitkam: Vertragslogo und
Kontoreihenfolge. Und wo der 409-Fall wartet: an POST
/transactions/{id}/transfer, also in Stufe 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:07:06 +02:00
GuidoandClaude Opus 5 82db09e6de feat: Dritte Stufe der iOS-App - alles laesst sich jetzt bearbeiten
Buchungen anlegen, aendern und loeschen; Budget-Zeilen setzen, fuer den Monat
oder fuer die Vorlage; Vertraege und Konten anlegen und aendern. Damit kann
die App alles, wofuer man bisher an den Rechner musste - ausser Import,
Arbeitsliste und Verwaltung.

Ein Formular je Sache, nicht zwei: Anlegen und Aendern teilen sich die Maske,
weil die Felder dieselben sind und zwei Kopien genau die Stelle waeren, an der
sie auseinanderlaufen. Was sich unterscheidet, steht in einem Zweck-Typ.

Geteilt in Bausteine/Formular.swift: ein Speichervorgang, der den Knopf sperrt
und den Fehler festhaelt (das Formular bleibt offen - zugeklappt waere die
Eingabe weg und die Meldung nutzlos), ein Betragsfeld, das an Cent gebunden
ist statt an Text, und die Auswahlfelder fuer Kategorie, Konto und
Schlagwoerter.

Drei Dinge, die stillschweigend falsch waeren:

Eine Teilaenderung schickt nur, was gesetzt ist. Ginge ein nicht angefasstes
Feld als null hinaus, loeschte jedes Speichern den Empfaenger, die Notiz und
die Kategorie gleich mit. Eine leere Tag-Liste geht dagegen sehr wohl mit -
sie bedeutet "alle abnehmen" und darf nicht mit "nicht angefasst"
zusammenfallen.

Bei einem Uebertrag wird keine Kategorie mitgeschickt: Das Geld wechselt nur
die Tasche, es wird nicht eingeordnet.

Bei Betriebsart "berechnet" bleibt planned_cents unberuehrt - das Backend
liest es dann nicht, und es zu ueberschreiben verloere den Wert, auf den ein
Zurueckschalten auf "fest" zurueckfallen soll.

Die Waehrung eines bestehenden Kontos laesst sich nicht aendern: Jeder
gespeicherte Betrag wuerde danach etwas anderes bedeuten.

49 Tests gruen. Texte in Deutsch und Englisch, zeilengleich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:04:46 +02:00
GuidoandClaude Opus 5 4c61a02c53 fix: Eine berechnete Budget-Zeile ueberlebt jetzt das Kopieren
Beide Kopierwege uebernahmen nur planned_cents. Ein Eintrag der Betriebsart
"berechnet" fiel dabei still auf "fest" zurueck - mit der Zahl, die zufaellig
zuletzt in planned_cents stand. Der Bezug zu den Vertraegen war danach weg,
ohne dass irgendwo stand, dass er weg ist.

Vorlage -> Monat (copy_template_to_month) zieht jetzt nach, was
ensure_customized nebenan laengst richtig macht. Monat -> Vorlage
(copy_month_to_template) nimmt die Betriebsart mit, statt die gerade
herauskommende Zahl einzufrieren: Eine berechnete Zeile soll auch in der
Vorlage berechnet bleiben.

Dabei aufgefallen: Eine berechnete Zeile gehoert auch dann in die Vorlage,
wenn planned_cents null ist - ihre Zahl steht in den Vertraegen. Eine feste
Null bleibt dagegen draussen, sonst fuellte sich die Vorlage mit Nullzeilen
fuer jede je beruehrte Kategorie.

Das war Backend-Ergaenzung D und damit die letzte offene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:04:46 +02:00
GuidoandClaude Opus 5 37b29934ef docs: Uebergabe, README und Fahrplan halten Stufe 2 fest
Was neu drinsteht, damit der naechste Agent es nicht selbst herausfinden muss:
die drei Entwurfsentscheidungen der Stufe 2 (Dashboard in einem Abruf,
Nachschlagewerk im AppZustand, Suche erst beim Absenden), die zwei Fallen der
Uebertrags-Paarung, und dass die Modellpruefung jetzt ein Test ist statt
Handarbeit - samt dem Befehl, die Pruefdatei neu zu erzeugen.

Naechster Schritt steht oben: Stufe 3, davor nur noch Backend-Ergaenzung D.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:25:11 +02:00
GuidoandClaude Opus 5 bf437b3b69 feat: Zweite Stufe der iOS-App - Uebersicht, Budget, Buchungen, Vertraege
Die App kann jetzt lesen, wofuer man sie oeffnet. Vier neue Reiter:

- Uebersicht: Einnahmen/Ausgaben des Monats, Budget-Fortschritt je Gruppe,
  Kontostaende und die letzten Buchungen. Feste Auswahl statt des
  verschiebbaren Rasters der Weboberflaeche - auf Telefonbreite waere das
  Arbeit ohne Gewinn. Vier Endpunkte nebeneinander in einem Abruf, damit der
  Offline-Stand nicht aus vier verschiedenen Momenten stammt.
- Budget: Soll gegen Ist je Gruppe und Kategorie, blaetterbar ueber prev/next
  aus der Antwort. Rot nur bei echter Ueberschreitung - wer mehr zuruecklegt
  als geplant, hat nichts ueberzogen. Vertraege ohne Kategorie und fehlende
  Wechselkurse stehen als Hinweis darunter, statt die Summen still zu
  verfaelschen.
- Buchungen: Suche und Filter laufen im Server (Backend-Ergaenzung B), nicht
  im Telefon. Gesucht wird beim Absenden, nicht je Tastendruck.
- Vertraege: Monats- und Jahressumme, Rhythmus, naechste Faelligkeit.

Zwei Dinge, die das Backend bewusst dem Client ueberlaesst:

Ein Uebertrag kommt in zwei Gestalten - am Stueck (eine Buchung mit
to_account_id) oder als zwei verknuepfte Buchungen, wie die Bank sie liefert.
Beide Beine als zwei Zeilen zu zeigen hiesse, dasselbe Geld zweimal zu zeigen.
Buchungszeile.zeilen fasst sie zusammen, aber nur wenn beide in der Liste
stehen - sonst liesse ein Kontofilter Zeilen verschwinden, die sehr wohl zu
diesem Konto gehoeren. Die Richtung kommt aus der Verknuepfung, nicht aus dem
Vorzeichen: Manche Banken liefern beide Beine positiv.

Kategorien, Schlagwoerter und Konten liegen als Nachschlagewerk im AppZustand,
einmal geladen. Je Ansicht neu zu holen waere genau das N+1, das das Backend
sich selbst verbietet.

Texte in Deutsch und Englisch, zeilengleich. 44 Tests gruen, darunter die
Uebertrags-Paarung und jedes Modell gegen eine echte Serverantwort.

Nicht geprueft: der Weg gegen ein echtes Backend. Die App laesst nur https zu,
ein lokaler Server haette eine ATS-Ausnahme gebraucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:24:06 +02:00
GuidoandClaude Opus 5 58bd504333 fix: Der Offline-Start zeigt wieder den letzten Stand
AppZustand faellt beim Start ohne Verbindung auf den gemerkten Benutzer
zurueck - nur schrieb diesen Schluessel niemand je in den Zwischenspeicher.
Der Zweig konnte also nie greifen, und die App verlangte im Flugmodus eine
neue Anmeldung, obwohl die Geraetemarke laengst da war.

Gefunden beim Durchsehen fuer Stufe 2: Nur Lader und das Nachschlagewerk
schreiben in den Speicher, und auth/me geht an beiden vorbei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:23:49 +02:00
GuidoandClaude Opus 5 a071422e23 test: Die iOS-Modelle werden gegen echte Serverantworten geprueft
Die Swift-Modelle sind aus den Pydantic-Schemas abgeschrieben. Ein vertippter
Feldname uebersetzt anstandslos und faellt erst dem Nutzer auf, wenn eine
Liste leer bleibt - das ist beim blinden Schreiben die einzige Luecke, die
kein Uebersetzer schliesst.

backend/scripts/erzeuge_ios_fixture.py legt eine Wegwerfdatenbank an, befuellt
sie so, dass jedes Feld einmal belegt ist (Tags, Uebertrag am Stueck,
verknuepfter Uebertrag, Vertragszuordnung, gebundenes Konto, jaehrliches Abo)
und schreibt 15 echte Antworten nach ios/Tests/Fixtures/antworten.json. Der
Swift-Test "Serverantworten" entschluesselt daraus jedes Modell.

Nach jeder Schema-Aenderung neu laufen lassen und das Ergebnis mit committen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:23:49 +02:00
GuidoandClaude Opus 5 3dff435156 docs: Uebergabe haelt B und C als erledigt fest und berichtigt den Testbefehl
Backend-Ergaenzungen B und C sind gebaut; Stufe 2 hat damit alles, was sie
braucht. Offen bleibt D, die zwei Kopier-Luecken im Budget.

Dazu berichtigt: Der Testbefehl zeigte auf einen Windows-Pfad, und die zwei
Tests, die als "vorbekannt rot" galten, laufen auf diesem Rechner durch - das
war ein Umgebungsproblem der alten Maschine. Erwartung ist jetzt: 438 Tests,
alle gruen. Wer einen roten sieht, hat ihn verursacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:10:21 +02:00
GuidoandClaude Opus 5 82edc29dfe fix: Die Budget-Vorlage nennt wieder Betriebsart und Zuschlag
GET /api/budget/template gab je Zeile nur planned_cents heraus, obwohl das
Modell modus und zuschlag_cents fuehrt und PUT sie zurueckgibt. Wer die
Vorlage so liest, haelt eine berechnete Zeile fuer eine feste - und schreibt
sie beim naechsten Speichern auch als feste zurueck.

Die Weboberflaeche traf es nicht, weil sie die Betriebsart aus der
Monatsansicht nimmt. Die iOS-App bietet beide Richtungen an und haette den
Fehler sichtbar gemacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:10:21 +02:00
GuidoandClaude Opus 5 1649c84f6a feat: Die Buchungsliste laesst sich im Server durchsuchen und filtern
Drei neue Abfrageparameter an GET /api/transactions: q sucht in Empfaenger,
Verwendungszweck und Notiz, tag_id filtert auf ein Etikett, subscription_id
auf einen Vertrag. Bisher gab es das nur im Browser - die Weboberflaeche laedt
alle Buchungen und siebt sie dort. Am Telefon ist das der Unterschied
zwischen "laedt" und "benutzbar"; das Limit steht nicht umsonst auf 5000.

Zwei Dinge, die die Tests festhalten: Prozent- und Unterstrich-Zeichen im
Suchtext sind Zeichen, keine Platzhalter - sonst faende die Eingabe "50%"
alles. Und ein fremder tag_id liefert nichts statt alles, weil der Filter auf
TransactionTag.owner_id anschlaegt.

Der Tag-Filter nimmt eine Unterabfrage wie der uncategorized-Zweig daneben,
keinen Join: Ein Join gaebe jede Buchung so oft zurueck, wie sie Tags traegt.
Die lokale Abfragevariable hiess q und heisst jetzt abfrage, damit der
Parameter den Namen bekommen konnte.

Bekannte Grenze: SQLite gleicht bei LIKE nur ASCII gross/klein ab, "Mueller"
mit Umlaut findet also nicht jede Schreibweise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:10:21 +02:00
GuidoandClaude Opus 5 e2f4e755f1 docs: Uebergabe und README sagen nicht mehr, dass es kein Xcode gibt
Die wichtigste Aussage der Uebergabe war ueberholt: auf dieser Maschine liegt
Xcode 27, Stufe 1 ist gebaut, getestet und im Simulator gestartet. Wer das
nicht weiss, schreibt weiter blind und hinterlaesst dem Nutzer Fehler.

Dazu neu festgehalten: der Befehl nach jeder neuen Datei (xcodegen generate),
was beim Bauen auffiel (nur Swift 5, nicht Swift 6), und der naechste
Stolperstein - App Transport Security laesst nur https zu, auch kein
http://localhost. Ueber die Verbindung geht die Geraetemarke, ein Vollzugang;
die Ausnahme ist deshalb eine Entscheidung des Nutzers, keine Nebensache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 05:47:23 +02:00
GuidoandClaude Opus 5 ffe1870bb8 build: Die iOS-App laesst sich mit einem Befehl bauen, starten und testen
Bisher musste das Xcode-Projekt von Hand angelegt werden, und der Swift-Code
wurde blind geschrieben, ohne ihn je zu uebersetzen. ios/project.yml
beschreibt das Projekt jetzt in 50 Zeilen; xcodegen generate macht daraus
Certain.xcodeproj. Das erzeugte Projekt liegt mit im Repository, damit man es
ohne xcodegen einfach oeffnen kann - geaendert wird aber die project.yml.

Festgelegt nach dem Muster von Vorrania: Buendelkennung com.scarriffle.certain,
Team PP34X97WS3, iPhone und iPad, Entwicklungssprache Deutsch, iOS 26 als
Mindestversion. Sprachfassung Swift 5, weil der Code nicht auf die strikte
Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben ist.

Stufe 1 uebersetzt damit fehlerfrei, die 28 Tests laufen durch, und die App
startet im Simulator bei der Serverabfrage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 05:45:42 +02:00
GuidoandClaude Opus 5 ca83bc7e13 fix: Die Netzwerkschicht uebersetzt - leeres Dictionary braucht [:]
Der Vorgabewert von weitereFelder in schickeDatei stand auf [], was Swift als
leeres Array liest. Der einzige Uebersetzungsfehler in den 2600 blind
geschriebenen Zeilen der Stufe 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 05:45:42 +02:00
ScarriffleandClaude Opus 5 f1b581d213 docs: Uebergabe fuer die iOS-App, damit der naechste Agent weiterarbeiten kann
Der Plan lag bisher ausserhalb des Repositories und waere beim Geraetewechsel
verloren gewesen. ios/UEBERGABE.md haelt jetzt alles fest: die Entscheidungen
des Nutzers, die Eigenheiten des Backends (Cookie-Anmeldung, Zeitstempel ohne
Zone, Uebertraege in zwei Mechanismen), was Stufe 1 enthaelt, was die drei
offenen Backend-Ergaenzungen sind und was die Stufen 2 bis 7 bringen.

Dazu die Konventionen des Projekts und die Fallen, die mich Zeit gekostet
haben - damit sie niemand zweimal findet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 21:53:06 +02:00
ScarriffleandClaude Opus 5 6b86a6bf91 feat: Erste Stufe der iOS-App - Anmeldung und Kontenliste
Neuer Ordner ios/ mit dem Geruest: Netzwerkschicht, Modelle, Offline-Ablage,
Texte in Deutsch und Englisch, Navigation, der komplette Anmeldeweg (Server
waehlen, anmelden, zweiter Faktor, Pflicht-Passwortwechsel) und als erste
Fachansicht die Kontenliste.

Die Anmeldung laeuft einmal ueber das Sitzungs-Cookie, holt sich dabei eine
App-Marke und legt sie in den Schluesselbund. Danach traegt nur noch der
Header. Kein Passwort auf dem Geraet, kein Ablauf nach 30 Tagen.

Der Code ist nie uebersetzt worden - hier gibt es kein Xcode. Geprueft ist
dafuer jedes Modellfeld gegen echte Antworten des laufenden Backends; dabei
kam heraus, dass created_at ohne Zeitzone und mit Mikrosekunden kommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 16:20:03 +02:00
ScarriffleandClaude Opus 5 8a7545bf5b feat: Eine Geraetemarke kann jetzt auch als Anmeldung fuer eine App gelten
Die API kannte zwei Zustaende: angemeldet per Sitzungs-Cookie, oder eine enge
Kurzbefehl-Marke, die nur erfassen darf. Eine native App passt in keinen davon
- sie kann kein Cookie-Formular ausfuellen und soll das Passwort nicht auf dem
Geraet halten.

Neu ist die Reichweite "app": Sie gilt im Header X-Certain-Token wie eine
Anmeldung, laeuft nicht nach 30 Tagen ab und laesst sich einzeln widerrufen.

Die enge Marke bleibt eng - das ist der Punkt, an dem die Tests am genauesten
hinsehen. Und eine App-Marke darf keine weitere ausstellen: Sonst stellte sich
ein Dieb Ersatzschluessel aus und das Widerrufen liefe ins Leere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 16:06:41 +02:00
ScarriffleandClaude Opus 5 15b863ff5e fix: Bearbeiten und Loeschen stehen wieder sichtbar in jeder Zeile
Mit Notiz und Tags ist die Buchungsliste breiter als der Bildschirm geworden.
Die Knopfspalte scrollte damit einfach aus dem Bild - und weil sie in einer
gespeicherten Spaltenreihenfolge auch noch mittendrin stand, war sie gar
nicht mehr zu finden. "fixed" hiess bisher nur "nicht verschiebbar", nicht
"bleibt stehen".

Angeheftete Spalten (neu: pin) kleben jetzt am rechten Rand und ruecken
immer ans Ende, egal was gespeichert ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 11:48:07 +02:00
ScarriffleandClaude Opus 5 637f5f4a70 feat: Die Notiz laesst sich direkt in der Liste schreiben
Bisher musste man dafuer die ganze Buchung aufmachen. Jetzt steht in der
Notiz-Spalte ein Feld: hineinschreiben, Enter oder wegklicken - fertig.
Escape nimmt die Aenderung zurueck, leeren loescht die Notiz.

Und sie steht nicht mehr doppelt da. Die dritte Zeile unter der Buchung
zeigte dieselbe Notiz wie die neue Spalte; sie faellt weg, der
Verwendungszweck darueber bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 11:24:13 +02:00
ScarriffleandClaude Opus 5 2ba307f833 fix: Die hohe Sicherheitsluecke im Build-Werkzeug ist weg
source-map-js 1.2.1 -> 1.2.2 (GHSA-68fv-2mgg-jv7q). Eine reine
Build-Abhaengigkeit ueber Vite und PostCSS, sie landet nie im Browser.

Die beiden verbleibenden Meldungen betreffen react-router und waeren nur mit
dem Sprung auf Version 7 zu beheben. Beide greifen hier nicht: Der offene
Redirect braucht ein Navigationsziel aus Nutzereingaben, es gibt aber nur
feste Pfade; die zweite betrifft SSR-Hydration, und die App ist ein reiner
SPA ohne SSR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 11:24:05 +02:00
ScarriffleandClaude Opus 5 498774b0da feat: Die Buchungsliste zeigt die Notiz und laesst waehlen, welche Arten erscheinen
Die Notiz stand nur im Bearbeiten-Dialog, obwohl man sie schreibt, um sie
spaeter wiederzufinden. Jetzt hat sie eine eigene Spalte mit eigenem Filter;
der ganze Text steht im Tooltip. Das Feld im Dialog ist wieder einzeilig und
laesst sich bei Bedarf aufziehen.

Statt einer Einfachauswahl gibt es drei Haken: Ausgaben, Einnahmen,
Uebertraege. Man will oft zwei Arten gleichzeitig sehen und eine dritte
weglassen.

Und "nur Einnahmen" zeigt keine Uebertraege mehr. Eine verknuepfte Buchung ist
weder Ausgabe noch Einnahme, sie verschiebt nur Geld - nach ihrem rohen Typ
gefiltert stand sie trotzdem unter den Einnahmen, und weil der Server nur eine
Seite des Paares lieferte, sogar einzeln statt als eine Zeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 11:17:37 +02:00
ScarriffleandClaude Opus 5 50fc0e3353 feat: Buchungen lassen sich mit eigenen, farbigen Tags versehen und danach filtern
Die Kategorie beantwortet "wofuer war das Geld" und gilt genau einmal. Alles,
was quer dazu liegt – "Urlaub Island", "absetzbar", "zurueckfordern" –, hatte
bisher keinen Platz, weil eine Buchung davon mehrere auf einmal sein kann.

Tags werden auf einer eigenen Seite angelegt, jeder mit seiner Farbe. Gesetzt
werden sie an der Buchung: direkt in der Liste oder beim Bearbeiten. Die
Spalte laesst sich filtern, auch nach mehreren Tags gleichzeitig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:16:17 +02:00
ScarriffleandClaude Opus 5 a9ea0432d8 fix: Eine Sicherung mit Transfer-Schlagwort laesst sich wieder einspielen
Das feste Zielkonto eines Transfer-Schlagworts behielt beim Wiederherstellen
die alte Konto-Nummer. Die Nummern werden dabei aber neu vergeben, also lief
es ins Leere – und zwar so, dass die GANZE Wiederherstellung mit einem
Fremdschluessel-Fehler abbrach, nicht nur diese eine Zeile. Wer je ein
Zielkonto gewaehlt hatte, kam an seine Sicherung nicht mehr heran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:16:03 +02:00
ScarriffleandClaude Opus 5 e8c18adafb fix: Das Notizfeld sieht aus wie die anderen Felder, statt als winziges Kaestchen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 06:54:18 +02:00
ScarriffleandClaude Opus 5 14254b483e fix: Die Meldung sagt jetzt, wenn die Gegenseite schon anderweitig verbunden ist
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 06:53:47 +02:00
ScarriffleandClaude Opus 5 b1f5eb9a51 fix: Eine Gegenseite, die Tage spaeter gebucht wurde, wird jetzt zum Verbinden vorgeschlagen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 08:30:40 +02:00
ScarriffleandClaude Opus 5 f52113e05d fix: Schon verbundene Buchungen stehen nicht mehr bei jedem Regel-Lauf unter "uebersprungen"
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 07:56:37 +02:00
ScarriffleandClaude Opus 5 54ff6d6896 fix: Zwei Uebertraege ueber denselben Betrag sind erlaubt – es wird nur gefragt
Zwischen zwei Konten liess sich kein zweiter Uebertrag ueber denselben
Betrag buchen. Die Begruendung stimmte fuer den haeufigen Fall: Beide
Seiten kommen oft getrennt herein, einmal von der Bank, einmal aus der
Datei – wer beide einzeln verknuepft, verschiebt das Geld zweimal.

Nur ist sie nicht immer richtig. Revolut-Aufrundungen sind reihenweise
CHF 1.00 zwischen demselben Paar in denselben Tagen, und die sind alle
echt. Als einziger Ausweg blieb, den vorhandenen Uebertrag zu loeschen.

Jetzt wird gefragt statt verboten: dieselbe Warnung, aber ueber
"trotzdem" zu uebergehen – der Weg, den alle anderen Pruefungen hier
schon nehmen. Entscheiden muss das der Nutzer; die Regel kann es nicht
wissen.

Dabei fiel auf, dass die Rueckfrage HINTER dem Fenster lag, das sie
aufgeworfen hat: Modal rendert per Portal an den Koerper und liegt damit
spaeter im Baum, bei gleicher Ebene gewinnt der spaetere. Eine
Rueckfrage gehoert ueber das, wonach sie fragt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 10:45:06 +02:00
ScarriffleandClaude Opus 5 b867b272ba feat: Gleich aussehende Buchungen lassen sich behalten, statt loeschen zu muessen
Die Liste sagt ausdruecklich, dass sie nichts entscheidet: Drei
Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche Einkaeufe
koennen es auch sein – das weiss nur der Nutzer. Dann darf sie aber auch
nicht zu einer Entscheidung draengen, und genau das tat sie: Der einzige
Knopf war der Papierkorb. Wer die Meldung loswerden wollte, musste eine
richtige Buchung wegwerfen.

Jetzt steht neben jeder Gruppe "Alle behalten". An den Buchungen aendert
sich dabei nichts; es ist ein Haken an der Frage.

Der Haken gilt dem, was man gesehen hat – nicht allem, was noch kommt:
Eine Gruppe faellt erst weg, wenn ALLE ihre Zeilen ihn tragen. Kommt
spaeter eine dritte gleich aussehende Buchung dazu, steht die Frage
wieder da, und zwar fuer alle drei. Ein Test haelt das fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 10:33:44 +02:00
ScarriffleandClaude Opus 5 6fc2a924d2 feat: Die Abo-Auswahl zeigt das Markenlogo – und steht alphabetisch
Das native Dropdown fiel neben der Kategorie-Auswahl auseinander: anderer
Stil, kein Suchfeld, kein Symbol, und die Vertraege standen in
Anlagereihenfolge. Bei zwanzig Abos sucht man einen bestimmten, und die
Reihenfolge der Eingabe kennt niemand.

Jetzt dieselbe Auswahl wie bei der Kategorie. CategorySelect bekommt dafuer
zwei kleine Zugaenge statt eines zweiten Dropdowns: ein eigenes Symbol je
Eintrag (renderMarke) und eine Gruppe ohne Namen, die ohne Kopfzeile
auskommt – die Vertraege haben keine Gruppen. Alles andere, von der
Suche bis zum Schliessen beim Scrollen, bleibt eine Mechanik.

Das Logo schlaegt dabei das Symbol: Netflix erkennt man am roten N, nicht
am Wort. Die Fallunterscheidung dafuer stand schon in der Abo-Liste und
ist jetzt eine Komponente, die sich beide teilen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 10:25:17 +02:00
ScarriffleandClaude Opus 5 0ea2ab1322 feat: Buchungen lassen sich von Hand einem Abo zuordnen – und das merkt sich
Die Erkennung lief seit Stufe 3, aber nur automatisch: Was sie nicht
sicher traf, blieb liegen, und sehen konnte man davon nichts. Die
Endpunkte gab es, die Oberflaeche kannte keinen davon.

Jetzt steht in der Buchungsliste eine Spalte "Abo" – dieselbe Form wie
die Kategorie daneben: Die Auswahl zeigt, zu welchem Vertrag die Zahlung
gehoert, aendert es, und leer loest die Zuordnung. Uebertraege bleiben
aussen vor; sie verschieben nur Geld.

Und sie lernt. Musste man von Hand ran, hat der Erkennungstext nicht
gepasst – also wird der Empfaenger dieser Buchung der neue. Beim
naechsten Mal faellt dieselbe Zahlung von selbst zu, statt dass man
jeden Monat dasselbe klickt. Der Vertragsname bleibt als zweite Chance:
_passt_text prueft beide, nicht entweder/oder, sonst verlaere man beim
Lernen, was vorher schon getroffen hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 09:59:55 +02:00
ScarriffleandClaude Opus 5 493a98ab6b fix: Auf der Uebersicht stand nicht, welcher Betrag gebunden ist
Die Kontostands-Karte zeigte die Saeule 3a wie jedes andere Konto: ein
Name, ein Betrag. Dass man an das Geld nicht herankommt, stand nur in
der Summe darunter – und wer den Betrag liest, rechnet ihn im Kopf doch
mit. Die Zeile traegt jetzt dieselbe Marke wie auf der Kontenseite.

Dabei ist ein Fehler aufgefallen, den ich selbst eingebaut hatte: Die
Zeile "zusaetzlich gebunden" stand im Markup HINTER der Summe. Die
klebt per position:sticky am unteren Rand des Rollbereichs – alles
dahinter liegt darunter und ist praktisch nie zu sehen. Summe und
gebundener Betrag stehen jetzt gemeinsam im angehefteten Fuss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 21:31:19 +02:00
ScarriffleandClaude Opus 5 eb72f0cb27 feat: Einer Buchung sieht man an, ob sie ein Uebertrag ist – und was sie war
Drei Dinge, die zusammengehoeren.

1. Uebertraege sind als solche lesbar.

Stand nur eine Seite in der Liste – weil nach einem Konto gefiltert wird
oder die andere nicht mitgeladen war – trug die Zeile den Rohtext der
Bank als Ueberschrift. "To CHF MB:8f33c0aa-9c92-456b-a6fe-b64a858a019c"
sah aus wie eine Zahlung an jemanden; die Verknuepfung stand klein
darunter. Jetzt steht oben, zwischen WELCHEN Konten das Geld lief, in
beiden Faellen gleich, und daneben eine Marke "Transfer". Ein Uebertrag
ist weder Ausgabe noch Einnahme – das gehoert in die erste Zeile, nicht
in eine Fussnote.

2. Notiz je Buchung.

Das Feld gab es im Modell seit jeher, die Oberflaeche bot es nie an. Die
Kategorie sagt, in welche Schublade etwas gehoert – nicht, WAS es war.
"Amazon" kann Einrichtung sein, Werkzeug oder ein Geschenk, und in drei
Monaten weiss das niemand mehr. Die Notiz steht in der Liste und ist
mitdurchsuchbar: Wer sie schreibt, will spaeter danach suchen.

3. "Ignorieren" ist keine Einbahnstrasse mehr.

Ein abgelehntes Paar blieb bei jedem weiteren Suchlauf stumm, und nichts
verriet, dass es je einen Vorschlag gab. Wer einmal falsch klickte,
suchte danach einen Fehler in der Erkennung, den es nicht gab. Die
abgelehnten Paare lassen sich jetzt ansehen und zurueckholen.

Und wenn die Vorschlaege nicht geladen werden koennen, wird das gesagt
statt verschluckt – sonst sieht die Liste aus, als gaebe es nichts zu
verbinden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 20:49:01 +02:00
ScarriffleandClaude Opus 5 76dbd3b481 feat: Geld, an das man nicht herankommt, zaehlt nicht mehr als verfuegbar
Eine Saeule 3a ist gesperrt – bis zur Rente, zur Selbstaendigkeit oder
zur Auswanderung. Bisher zaehlte ihr Stand in dieselbe Summe wie das
Girokonto, und die Antwort auf "wie viel habe ich" war um genau diesen
Betrag zu hoch. Das Geld steht da und ist doch nicht da.

Konten lassen sich jetzt als gebunden kennzeichnen. "Gesamt" zeigt dann
nur noch, was wirklich ausgegeben werden kann; darunter steht
"zusaetzlich CHF 2'004.61 gebunden" – aufklappbar, mit jedem Konto
einzeln. Eine Summe, die man nicht nachrechnen kann, muss man glauben.

Verschwiegen wird nichts: Der Saldo bleibt am Konto stehen, und
Vermoegensverlauf wie Nettovermoegen rechnen weiter damit. Es nicht
ausgeben zu koennen heisst nicht, es nicht zu besitzen – das ist der
ganze Unterschied, und ein Test haelt ihn fest.

Ein eigenes Kennzeichen, keine Ableitung aus dem Kontotyp: Ein frei
verkaeufliches Depot und eine gesperrte Saeule 3a kommen beide als
"depot" herein. Am Typ laesst sich das nicht ablesen – am
ausdruecklichen Eintrag schon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 20:29:34 +02:00
ScarriffleandClaude Opus 5 35c5d2c84a fix: Ein Saldo-Abgleich, der nicht mehr gilt, sagt das jetzt
Der Abgleich wird einmal serverseitig gerechnet, beim Erzeugen der
Vorschau. Wer danach eine Zeile von Hand umstellt, erreicht ihn nicht –
das gruene "stimmt" blieb stehen, obwohl es sich auf eine andere
Leserichtung bezog.

Mit der neuen Sammelaktion "Richtung umdrehen" waere das leicht
passiert: ein Klick, und die Bestaetigung darueber stimmt nicht mehr.

Nachgerechnet wird nicht – das waere eine zweite Wahrheit neben der
ersten. Stattdessen sagt der Hinweis, dass seine Aussage veraltet ist.
Beim Einlesen kommt die Art aus dem Vorzeichen; jede Abweichung ist
also nachweislich eine Handaenderung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 20:59:48 +02:00
ScarriffleandClaude Opus 5 2891270ac2 feat: Der Import fragt nur noch, was er nicht selbst weiss
Bisher standen alle Einstellungen offen nebeneinander – auch die, die
Certain laengst erkannt hatte. Rund zwanzig Bedienelemente fuer einen
Vorgang, bei dem im Normalfall keine einzige Entscheidung ansteht.

Jetzt: Datei waehlen, Pruefansicht. Dazwischen nichts.

- Format und Spaltenzuordnung klappen ein, solange die Erkennung sicher
  ist. Ein Klick auf "Alle Einstellungen anzeigen" holt sie zurueck –
  samt der Begruendung, WAS erkannt wurde und warum die Vorschau so
  aussieht, wie sie aussieht. Wer nachsehen will, kann; muessen soll
  niemand.
- Die Erkennungs-Meldung steht jetzt unter der Datei, auf die sie sich
  bezieht, statt weit unten zwischen den Spaltenfeldern.
- "Vorschau erstellen" entfaellt. Der Knopf tat genau das, was ohnehin
  als naechstes kommen musste; die Vorschau laeuft jetzt von selbst,
  entprellt, sobald Datum und Betrag zugeordnet sind. Fehlt etwas, steht
  da, was fehlt.
- "Als Vorlage speichern" ist keine primaere Aktion mehr. Das ist Pflege
  und sah aus wie eine Entscheidung, die jetzt ansteht.

Der Zeilenfilter klappt ausdruecklich NICHT mit ein. Revolut legt alle
Pockets in eine Datei; ohne Filter zaehlen die Bewegungen doppelt. Das
ist kein Detail fuer Fortgeschrittene, sondern die Bedingung dafuer,
dass die Zahlen stimmen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 20:58:38 +02:00
ScarriffleandClaude Opus 5 ea81cef45d feat: Die Kartenrechnung findet ihre Gegenbuchung von selbst
Eine Kreditkarte bekommt Geld nur aus einem Grund: weil ihre Rechnung
bezahlt wird. Das genuegt als Hinweis – und es wird gebraucht, denn im
Buchungstext steht bei so einer Zahlung oft weder ein Schlagwort noch
der Name des Gegenkontos. Die Erkennung fand das Paar deshalb, liess es
aber als Vorschlag liegen.

Entscheidend ist die RICHTUNG, nicht dass eine Karte beteiligt ist. Die
naheliegende Fassung "Kartenkonto auf einer Seite" haette einen
Karteneinkauf ueber 50,00 mit einer zufaelligen Gutschrift ueber 50,00
am selben Tag verknuepft – eine echte Ausgabe UND eine echte Einnahme
waeren still aus der Auswertung gefallen. Genau davor schuetzt die
Huerde, die hier gelockert wird. Ein Test haelt die Gegenprobe fest.

Die Schlagwortliste bleibt unberuehrt: "visa" und "mastercard" stehen in
gewoehnlichen Buchungstexten jedes Kontos.

Und der Import sagt jetzt, was er getan hat. Bisher lief die Suche nach
dem Buchen still; wer wissen wollte, ob die Kartenrechnung erkannt
wurde, musste auf "Zu erledigen" wechseln – und ahnte nicht, dass es da
etwas zu sehen gab. Was nicht automatisch geht, erscheint als Vorschlag
direkt auf der Import-Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 20:51:36 +02:00
ScarriffleandClaude Opus 5 98760a4652 feat: Kreditkarten-Auszuege lesen sich von selbst richtig herum
Auf einem Kartenauszug ist +6,00 eine AUSGABE und -10,00 die Zahlung der
Rechnung. Certain kannte diese Leserichtung nicht. Der scheinbare Ausweg
"Ein Betrag (immer Ausgabe)" machte auch die Rechnungszahlung und jede
Gutschrift zur Ausgabe.

Neuer Betragsmodus "signed_inverted". Er dreht Betrag UND Saldospalte:
Ein Auszug, der einen Einkauf als +6 fuehrt, schreibt auch den Saldo so –
die Schuld steigt. Wer nur den Betrag dreht, bekommt bei jeder einzelnen
Zeile eine Abweichung gemeldet, und die einzige Kontrollinstanz des
Imports wird wertlos. Dazu gibt es jetzt einen Test.

Waehlen muss den Modus niemand: /imports/detect erkennt jetzt auch das
KONTO – erkenne_konto konnte das laengst, benutzt hat es nur der
Telefon-Kurzbefehl. Ist das erkannte Konto eine Kreditkarte, wird die
Leserichtung mitgesetzt. Eng gefasst: nur bei einer Betragsspalte, nur
aus "signed" heraus, nur bei sicher erkanntem Konto. Und nie still – in
der Pruefansicht steht, dass umgekehrt gelesen wird, und wo man es
aendert.

Drei Dinge, die dabei auffielen:

- Der Abgleich rechnete mit dem Vorzeichen, gebucht wird aber nach Art
  (abs(Betrag) + Ausgabe/Einnahme). Wer eine Zeile umstellte, pruefte
  eine Buchung, die so nie entsteht.
- Dieselbe Luecke in der Vorschau: Die Betragsspalte faerbte nach dem
  Rohvorzeichen. Ein Umstellen aenderte sichtbar nichts.
- Auf Kartenauszuegen heisst die Empfaengerspalte "Haendler", und viele
  fuehren die eigene Kontonummer in jeder Zeile mit. Die gewann die
  Spaltenwahl ueber die Textlaenge – heraus kam eine Liste, in der bei
  jeder Buchung dasselbe stand.

Neu ausserdem eine Summenzeile je Richtung und eine Sammelaktion: Ist
eine Datei durchgehend verkehrt herum gelesen, sind alle Zeilen
betroffen, und bei 150 Positionen ist Einzelkorrektur keine Moeglichkeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 20:44:06 +02:00
ScarriffleandClaude Opus 5 38697f828e feat: Eine Kreditkarte ist eine Schuld – der Rahmen ist kein Guthaben
Wer seinen Kreditrahmen festhalten wollte, hatte dafuer nur den
Anfangssaldo. Also standen dort 2.500, und Certain meldete ein Vermoegen,
das es nicht gibt: Das Geld ist nicht da, es darf nur ausgegeben werden.

Jetzt hat der Rahmen ein eigenes Feld – sichtbar nur bei Kreditkarten,
denn an einem Girokonto gaebe es nichts zu begrenzen. Die Kontokarte
zeigt daneben, was noch geht ("noch 1.754,50 von 2.500,00 frei") und
warnt, wenn der Rahmen ueberzogen ist.

Der Saldo selbst bleibt, was er ist: abbezahlt null, jede Ausgabe ins
Minus, die Rechnung holt ihn zurueck. Gerechnet werden musste dafuer
nichts neu – die Saldenrechnung war schon vorzeichenoffen.

Zwei Dinge fielen dabei auf und sind miterledigt:

- Die Summe "Gesamt" auf der Kontenseite zaehlte archivierte Konten mit,
  das Backend nicht. Zwei Zahlen fuer dieselbe Sache.
- "Gesamt" wurde ungefaerbt ausgegeben. Solange nur Guthaben
  zusammenkamen, fiel das nicht auf; mit einer Kartenschuld stuende ein
  negatives Gesamt still und unauffaellig da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 20:27:30 +02:00
ScarriffleandClaude Opus 5 152483707e feat: Zahlungen finden ihren Vertrag von selbst – Preisaenderungen werden gemeldet
Ein Abo stand bisher unverbunden neben seinen Zahlungen. Wer wissen wollte,
ob Netflix diesen Monat abgebucht wurde, musste es in der Buchungsliste
suchen. Jetzt verknuepft Certain Buchung und Vertrag beim Import selbst.

Drei Stufen, bewusst unterschiedlich mutig:

- sicher (Betrag und Text passen, Datum im Fenster): wird zugeordnet.
- Vorschlag: Betrag passt, Text nicht – oder der Text passt und der Betrag
  weicht ab. Der zweite Fall ist die Preiserhoehung.
- nichts: im Zweifel lieber schweigen.

Eine Preiserhoehung wird nie stillschweigend uebernommen. Sie ist eine
Nachricht an den Nutzer, kein Wartungsvorgang – er entscheidet, ob der
neue Preis der richtige ist.

Die Zuordnung ist ein Etikett, keine Umbuchung: die Buchung selbst bleibt
unangetastet, und ein geloeschter Vertrag nimmt sie nicht mit.

Beim Backup wandern Vertraege nun vor die Buchungen, sonst haenge eine
Rueckspielung Zahlungen an die falschen Vertraege.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 20:16:13 +02:00
ScarriffleandClaude Opus 5 41db67f53e feat: Ein Benutzername laesst sich aendern – er ist ein Etikett, kein Schluessel
Aendert der Anmeldedienst den Namen eines Nutzers, muss Certain folgen
koennen. Bisher ging das gar nicht: Umbenennen war nirgends vorgesehen.

Die gute Nachricht vorweg – am Fundament war nichts zu richten. Buchungen,
Konten, Vertraege, Sitzungen und Geraeteschluessel haengen alle an der NUMMER
des Nutzers; der Name ist nirgends ein Fremdschluessel, und auch das
Wiedereinspielen einer Sicherung ordnet nicht ueber ihn zu. Umbenennen zieht
also nichts mit. Ein Test haelt das jetzt fest: Nach dem Umbenennen sind es
dieselbe Nummer, dasselbe Konto und derselbe Saldo.

Zwei Wege gibt es jetzt.

Der Admin kann umbenennen (PATCH /api/users/{id}). Eindeutig bleiben muss der
Name trotzdem – er ist das, was beim Passwort-Login eingetippt wird.

Und der Anmeldedienst zieht bei jeder Anmeldung nach. Dabei zaehlt, WESSEN
Name das jeweils ist: Der Anzeigename gehoert dem Anbieter – heiratet jemand,
soll der neue Name auch hier stehen. Der Benutzername gehoert bei einem Konto
MIT Passwort dem Nutzer; ihn hinter dessen Ruecken zu aendern hiesse, ihm
seine Anmeldung wegzunehmen. Nur bei einem reinen SSO-Konto gibt es nichts
einzutippen – dort darf der Anbieter bestimmen.

Ist der neue Name schon vergeben, bleibt der alte stehen. Eine Anmeldung
daran scheitern zu lassen waere die schlechtere Antwort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:00:52 +02:00
ScarriffleandClaude Opus 5 62cb6a017c fix: Ein unerreichbarer Anmeldedienst zeigte eine Seite mit rohem JSON
Auf "Mit Authentik anmelden" folgt der BROWSER einem Link – da steht kein
fetch(), das eine Fehlerantwort auswerten koennte. Scheiterte der Aufruf des
Anmeldedienstes (Tippfehler in der Discovery-Adresse, Name nicht aufloesbar),
landete der Nutzer auf einer weissen Seite mit {"detail": ...} und musste
zurueckdruecken.

Jetzt geht es zurueck in die Oberflaeche, mit dem Grund in der Adresse – genau
wie bei der Rueckkehr vom Anbieter. Beim Anmelden auf die Login-Seite, beim
Verknuepfen auf "Mein Konto".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:54:20 +02:00
ScarriffleandClaude Opus 5 bd2c623136 fix: Hinter einem Reverse Proxy zeigte die SSO-Rueckleitung nach innen
Die Adresse, an die der Anmeldedienst zurueckschickt, wurde aus der Anfrage
abgeleitet. Hinter einem Proxy ist das die INTERNE – http://10.0.0.5:8071/… –,
denn uvicorn lief ohne --proxy-headers. Der Anmeldedienst haette die
Rueckleitung abgewiesen, mit einer Meldung, die nicht sagt, woran es liegt.

APP_PUBLIC_BASE_URL sagt es ihm jetzt ausdruecklich. Leer bleiben darf sie
weiterhin: ohne Proxy und in der Entwicklung genuegt die Anfrage.

Die Referenz-Unit bekommt zusaetzlich --proxy-headers. Das hilft neuen
Installationen; bestehende ruehrt update.sh nicht an, deshalb ist die
Einstellung der verlaessliche Weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:39:51 +02:00
ScarriffleandClaude Opus 5 7690718731 feat: Anmelden ueber Authentik – zusaetzlich zu Benutzername und Passwort
Ein zweiter Weg herein, kein Ersatz. Wer weiter Passwort und Zwei-Faktor
benutzen will, merkt nichts: Ohne eingerichteten Anbieter sieht die
Login-Seite aus wie bisher.

DER WEG FUER BESTEHENDE KONTEN ist das Verknuepfen, nicht das Anmelden: einmal
wie gewohnt hereinkommen, unter "Mein Konto" auf "Mit Authentik verknuepfen",
fertig. Danach geht beides.

Was es ausdruecklich NICHT gibt, ist das automatische Zusammenfuehren ueber
die E-Mail. Es waere der bequeme Weg und der gefaehrliche: Wer beim Anbieter
eine fremde Adresse eintraegt, uebernaehme damit ein fremdes Konto – und die
E-Mail ist dort die Angabe, die sich am leichtesten aendern laesst. Deshalb
sagt der Nutzer selbst, wer er beim Anbieter ist, im angemeldeten Zustand.

Aus demselben Grund legt eine unbekannte Anmeldung standardmaessig KEIN Konto
an, sondern wird mit dem Hinweis abgewiesen, sich anzumelden und zu
verknuepfen. Sonst bekaeme genau der Mensch, der schon ein Konto hat, bei
seiner ersten SSO-Anmeldung ein zweites, leeres – und seine Daten laegen
weiter unter dem alten. Wer will, schaltet AUTO_CREATE ein.

Geprueft wird mit Authlib, nicht von Hand: Signatur gegen JWKS, Aussteller,
Empfaenger, Laufzeit und nonce. Dazu state gegen untergeschobene Rueckkehr und
PKCE, obwohl der Client vertraulich ist – es kostet nichts und der Code taugt
damit auch fuer einen oeffentlichen.

Ein zweiter Anbieter ist reine Konfiguration:
    APP_OIDC_PROVIDERS=authentik,keycloak

Nebenbei zwei Stellen abgesichert, die es vorher nicht geben konnte: Ein Konto
ohne Passwort faellt beim Passwort-Login nicht mehr in argon2 (das haette
verraten, dass es diesen Benutzernamen gibt), und die letzte Verknuepfung
laesst sich nicht loesen, solange es kein Passwort gibt – sonst schliesst sich
der Nutzer aus seinem eigenen Konto aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:28:24 +02:00
ScarriffleandClaude Opus 5 01ae12ffea fix: "davon ... fest gebunden" war irrefuehrend – und nicht nachpruefbar
Der Hinweis stand unter der Zeile und begann mit "davon". Die naechste Zahl
darueber ist der Ist-Betrag, also las es sich als "von den 17.66 sind 113.72
gebunden" – und wo die Vertraege mehr kosten als geplant, ergab es vollends
keinen Sinn. Die Rechnung stimmte; der Satz war falsch.

Jetzt steht schlicht da, was es ist: "4 Vertraege kosten CHF 113.72 im Monat".
Kein "davon", kein Bezug auf eine Zahl, zu der es keinen gibt. Kostet mehr als
geplant, sagt die Zeile das ausdruecklich, statt es der Rechnung zu ueberlassen.

Vor allem aber laesst sich die Summe jetzt AUFKLAPPEN: darunter steht jeder
einzelne Vertrag mit seinem Monatswert, und bei laengerem Intervall auch der
urspruengliche Betrag ("CHF 190.00 jaehrlich · 16.84 EUR"). Eine Summe, die man
nicht nachrechnen kann, muss man glauben – und wenn sie einem krumm vorkommt,
hilft nur Nachsehen.

Die Liste kommt aus derselben Quelle wie die Summe (monatlich_base_cents vom
Server). Stimmten die beiden nicht ueberein, waere das ein echter Fehler – und
man saehe ihn sofort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:35:02 +02:00
ScarriffleandClaude Opus 5 ab5d18e0d0 feat: Der Soll-Betrag kann sich aus den Vertraegen rechnen
Die Vertraege standen bisher nur auf ihrer eigenen Seite. Im Budget sah man
nichts davon – man musste selbst nachrechnen, was fest gebunden ist, und beim
naechsten Preisaufschlag noch einmal.

Jetzt hat jede Kategorie zwei Betriebsarten, umschaltbar ueber ein Sigma neben
dem Eingabefeld:

  fest        eine Zahl, die man hinschreibt. Wie bisher.
  berechnet   Summe der Vertraege dieser Kategorie plus einem Zuschlag.

Der Zuschlag ist der Punkt. Wer fuer Streaming 62 plant, weil 42 gebunden sind
und 20 Luft sein sollen, muss beim naechsten Preisaufschlag nicht nachrechnen
– und verliert seine 20 dabei auch nicht. Unter der Zeile steht die Rechnung
offen, sonst stuende im Feld eine Zahl, die nicht der Soll-Betrag ist.

Das gilt fuer JEDE Kategorie, nicht nur fuer eine Abo-Kategorie: Ein
Handyvertrag liegt in "Telefon & Internet", und die rechnet sich genauso.

Auch auf "fest" steht der Hinweis da ("davon 16.84 fest gebunden") – sonst
sieht man nie, was einen schon bindet.

Zwei Dinge, die sonst still danebengingen, melden sich jetzt oben auf der
Seite: Vertraege OHNE Kategorie (ihr Geld fehlt im Budget, ohne dass irgendwo
steht, dass es fehlt) und Waehrungen ohne Kurs.

Gerechnet wird in effective_planned – der einen Stelle, aus der Budget-Seite
UND Dashboard ihr Soll beziehen. Damit koennen die beiden gar nicht erst
auseinanderlaufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:49:21 +02:00
ScarriffleandClaude Opus 5 26718b1301 feat: Der Zuschnitt ist ein Rahmen, den man aufzieht
Zoomen war das falsche Werkzeug. Man konnte nur naeher heran, aber nicht
sagen, WAS man behalten will – und das Ergebnis war immer quadratisch, auch
wenn das Logo breit ist.

Jetzt liegt ein Rahmen auf dem Bild: ziehen zum Verschieben, acht Griffe zum
Aufziehen. Aussen abgedunkelt, Drittel-Linien zum Ausrichten, und darunter
steht, wie gross der Ausschnitt gerade ist. "Ganzes Bild" setzt zurueck.

Das Seitenverhaeltnis des Ausschnitts bleibt. Ein breites Logo wird ein
breites Logo, kein quadratisches mit Luft an den Seiten.

Der Ausschnitt wird in BILDPUNKTEN gefuehrt, nicht in Bildschirmpunkten –
umgerechnet wird nur fuer die Anzeige. So trifft das Ergebnis genau den
Bereich im Rahmen, ohne dass sich ueber viele Zieh-Bewegungen Rundungsfehler
aufaddieren.

Am Bildrand haelt die gegenueberliegende Kante still, statt dass der Rahmen
beim Anschlag mitwandert; zieht man einen Griff ueber die eigene Kante
hinaus, bleibt sie stehen, statt sich umzustuelpen. Auf dem Telefon sind die
Griffe groesser – mit dem Finger waeren vierzehn Pixel nicht zu treffen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:59:11 +02:00
ScarriffleandClaude Opus 5 9b9ce77267 feat: Das Logo laesst sich zuschneiden
Ein Screenshot von einer Website ist selten schon das Logo – da ist Rand
drumherum oder drei Marken nebeneinander. Nach dem Waehlen kommt deshalb ein
Rahmen: ziehen zum Verschieben, Regler zum Zoomen.

Der Rahmen IST der Ausschnitt. Deshalb nichts abgedunkelt und keine
Hilfslinien darin – man soll das Ergebnis sehen und nicht eine Vorschau
davon, die man erst uebersetzen muss.

Zu Beginn steht das GANZE Bild im Rahmen. Wer nicht zuschneiden will, drueckt
Uebernehmen und bekommt, was er hochgeladen hat; Zuschneiden ist ein Zoom,
kein Zwang. Verschieben geht nur, solange das Bild den Rahmen ueberragt –
sonst schoebe man ein kleines Bild aus seinem eigenen Ausschnitt heraus.

Geschnitten wird im Browser, hochgeladen nur noch der Ausschnitt: Der Server
bekommt kein Bild, das er gar nicht behalten soll.

Der durchsichtige Grund bleibt durchsichtig – ein weisser Kasten fiele auf
dunklem Hintergrund sofort auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:50:16 +02:00
ScarriffleandClaude Opus 5 dd2c33725f feat: Ein Markenlogo je Vertrag
Am Logo findet man den Vertrag schneller als am Namen: Netflix erkennt man am
roten N, nicht am Wort – und erst recht nicht an einem Zahnrad aus der
Symbolauswahl. Die bleibt als Rueckfall fuer alles, wozu es kein Logo gibt.

Hochgeladen wird, was das Telefon hergibt; gespeichert ein PNG von hoechstens
128 Pixeln Kantenlaenge. Aus drei Megabyte werden ein paar Kilobyte, und das
Seitenverhaeltnis bleibt – ein verzerrtes Logo sieht falscher aus als ein
kleines.

Immer PNG, egal was hereinkam. Das erspart der Anzeige jede
Fallunterscheidung und schliesst SVG aus: Eine SVG-Datei kann Skript
enthalten, und ein Bild, das der Nutzer hochlaedt und der Browser ausfuehrt,
ist genau die Art Falle, die man sich nicht in ein Finanzprogramm legt.

Eigene Tabelle, nicht eine Spalte an den Vertraegen: Sonst zoege jede
Listenabfrage saemtliche Bilder mit heraus, obwohl die Liste sie einzeln
nachlaedt.

ZWEI FEHLER IM SICHERN, die dabei aufgefallen sind:

Binaeres liess sich gar nicht sichern – die Ausgabe scheiterte an den Bytes,
und zwar erst beim Schreiben, also nachdem der Nutzer glaubt, ein Backup zu
haben. Jetzt Base64.

Und "Ergaenzen" legte Zeilen von Tabellen mit natuerlichem Schluessel ein
zweites Mal an, statt sie wiederzuerkennen. Beim Logo kippte damit die ganze
Wiederherstellung. Betraf bisher keine Tabelle, weil noch keine solche einen
Vergleichsschluessel hatte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:35:37 +02:00
ScarriffleandClaude Opus 5 0a790fd45a feat: Nur die Waehrungen zeigen, die man braucht – und auf dem Telefon lesbar
Drei Sachen, die beim Benutzen aufgefallen sind.

ANGEBOTENE WAEHRUNGEN. In den Einstellungen steht jetzt, welche Waehrungen in
den Auswahlfeldern erscheinen (Vorgabe EUR, CHF, USD). Dass dreissig
unterstuetzt werden, ist gut; dreissig Zeilen in einem Feld zu sehen, wenn man
drei davon braucht, ist es nicht.

Die Einstellung beschraenkt nur die ANZEIGE. Gueltig bleibt alles mit
EZB-Kurs, und die gerade gesetzte Waehrung steht IMMER im Feld – sonst stuende
ein Konto in Dollar vor einem Feld ohne Dollar und wechselte beim naechsten
Speichern stillschweigend die Waehrung. Der Saldo waere danach still falsch.
Die Hauptwaehrung laesst sich nicht abwaehlen.

ZEICHEN STATT KUERZEL, wo es hilft: "€ EUR", "$ USD", "£ GBP". CHF steht
bewusst ohne: "Fr." ist weniger gelaeufig als CHF selbst. Mehrfach belegte
Zeichen bleiben ebenfalls draussen – "US$" von "CA$" zu unterscheiden ist
Raetselraten.

AUF DEM TELEFON. Die Vertragszeile quetschte vier Spalten in 375 Pixel; vom
Namen blieb "Sal..." uebrig und von der Kategorie ein Punkt. Sie ist jetzt ein
Gitter: Unter 620 Pixel rutscht der Betrag unter den Namen, die Knoepfe
bleiben rechts. Der Name bekommt die volle Breite – auf dem Telefon ist er das
Einzige, woran man die Zeile erkennt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:16:45 +02:00
ScarriffleandClaude Opus 5 f99be47587 feat: Abos & Vertraege – was regelmaessig abgeht, an einer Stelle
Netflix, Handyvertrag, Krankenkasse, Halbtax. Bisher sah man sie nur als
einzelne Buchungen im Auszug; die Frage "welche Abos habe ich eigentlich, und
was kosten die zusammen im Monat" liess sich nicht beantworten. Genau die
steht jetzt oben auf der Seite.

Der Bereich heisst "Abos & Vertraege", nicht "Abos": Ein Handyvertrag ist kein
Abo und die Versicherung erst recht nicht – niemand soll ueberlegen muessen,
wo sie hingehoert.

Der Monatswert wird gerechnet, nicht gespeichert (Betrag durch Intervall).
Gespeichert veraltete er beim naechsten Preisaufschlag stillschweigend.

Bei laengerem Intervall klappt "Ich lege monatlich zurueck auf" auf, mit dem
gerechneten Monatsbetrag daneben. Bei monatlicher Abrechnung gibt es nichts
anzusparen, also steht es da auch nicht.

DAZU: Waehrungen sind offen.

Die Rechen-Maschinerie konnte immer schon beliebige Waehrungen – sie rechnet
ueber den Euro als Drehpunkt. Beschraenkt haben es zwei Konstanten: eine
Erlaubnisliste mit EUR und CHF, und ein Filter, der aus dem EZB-Feed alles
ausser CHF wegwarf, obwohl er dreissig Waehrungen liefert.

Massgeblich ist jetzt die Liste der EZB. Frei erfundene Waehrungen bleiben
draussen, und das ist kein Geiz: Ohne Kurs liesse sich der Betrag nie
umrechnen – er stuende in keiner Summe und in keinem Budget, und das faellt
erst spaet auf. Ein Vertrag in einer Waehrung ohne Kurs wird deshalb genannt,
statt die Summe stillschweigend zu klein anzuzeigen.

Erkannt und gebucht wird noch nichts; der Erkennungstext wird nur erfasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:45:59 +02:00
ScarriffleandClaude Opus 5 ff621984dc feat: Eine Ausgabe, die kein Verlust ist
Sparen zaehlt im Budget wie eine Ausgabe – zu Recht, das Geld ist diesen Monat
gebunden. Es stand danach aber in Rot da, und wer tausend zuruecklegte statt
achthundert, bekam eine Warnung fuer etwas Gutes.

Ein Haken an der Kategorie stellt das ab. Zwei Folgen:

Der Betrag steht neutral. NICHT gruen: Gruen heisst in Certain ueberall "Geld
kommt rein", und CHF 1'010 in Gruen liest sich als Einnahme. Grau sagt genau
das Richtige – kein Verlust, kein Gewinn.

Und mehr als geplant ist keine Ueberschreitung mehr. Das gilt auch eine Ebene
hoeher: Eine Gruppe misst sich an dem, was WIRKLICH zu viel war. Sonst faerbt
sich "Finanzen" rot, weil jemand gespart hat, und die echte Ueberschreitung
daneben faellt nicht mehr auf. Angezeigt werden weiter die vollen Summen; nur
das Urteil laesst das Sparen aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:28:36 +02:00
ScarriffleandClaude Opus 5 15c2a164ec feat: Die Summe bleibt stehen, waehrend die Liste durchlaeuft
Bei mehr als ein paar Konten rollte "Gesamt" mit aus der Karte heraus – die
eine Zahl, wegen der man auf die Karte schaut, war damit die einzige, die man
suchen musste.

Sie klebt jetzt am unteren Rand. Gilt auch fuer die Ausgaben-Anteile: dieselbe
Zeile, dasselbe Problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 14:48:42 +02:00
ScarriffleandClaude Opus 5 cea238149b fix: Bei einem langen Formular war "Speichern" nicht erreichbar
Der Dialog wuchs mit seinem Inhalt und lief unten aus dem Bild. Scrollen ging
nicht – damit war der Knopf schlicht weg, und das Konto liess sich nicht mehr
speichern.

Jetzt bleibt der Dialog im Fenster: Kopf und Knopfleiste stehen fest, nur der
Inhalt dazwischen rollt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 14:39:08 +02:00
ScarriffleandClaude Opus 5 b69c383a72 fix: "Nicht in Auswertungen einrechnen" galt fuer Sparen nicht
Wer den Haken an einer Kategorie setzt, sagt damit ausdruecklich: nicht ins
Budget, nicht ins Dashboard, nicht in die Statistik. Die Sparen-Auswertung
las ihn nicht – das Geld stand danach im Ringdiagramm, obwohl der Nutzer
genau das abbestellt hatte.

Beide Uebertrags-Wege halten sich jetzt daran, der am Stueck wie der aus zwei
verknuepften Buchungen. Der Haken an der Kategorie gewinnt; ein Sparziel am
Konto darf ihn nicht stillschweigend aushebeln.

Und weil beides zusammen keinen Sinn ergibt, sagt es das Konto-Formular jetzt
auch: Waehlt man dort eine Kategorie mit Haken, steht darunter, dass so nichts
im Budget erscheinen wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 14:13:22 +02:00
ScarriffleandClaude Opus 5 e0688ab95e fix: Die Kontenseite stuerzte nach dem Sparziel-Feld ab
Das Laden der Kategorien landete in der falschen Komponente – in
TransferKeywords statt in Accounts. Dort gibt es kein `groups`, also warf die
Seite beim Aufbau "groups is not defined" und blieb leer.

Der Bau lief trotzdem durch: Es ist gueltiges JavaScript, nur eben mit einem
Namen, den es an dieser Stelle nicht gibt. Auffallen konnte das erst beim
Oeffnen der Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 14:03:01 +02:00
ScarriffleandClaude Opus 5 25392b9d08 feat: Ein Konto kann sagen, dass Ueberweisungen darauf Sparen sind
Wer auf ein Pocket spart, budgetiert dafuer eine Ausgabe – im Budget stand
davon aber nichts. Zwei Gruende:

Erstens gibt es zwei Formen von Uebertrag. Die eine ist EINE Buchung mit
Zielkonto, wie sie das Formular anlegt; nur fuer die gab es die Sparen-Logik.
Die andere sind ZWEI verknuepfte Buchungen, und genau so kommt ein Uebertrag
ueber die Bankanbindung herein. Beide Seiten uebersprang die Auswertung
ausnahmslos.

Zweitens hing das Sparen am Kontotyp. Ein Revolut-Pocket ist bei der Bank ein
ganz normales Konto und kommt als "girokonto" herein – am Typ ist nicht
abzulesen, dass man darauf spart.

Jetzt sagt man es dem Konto einmal ("Sparen gegen Kategorie X"), und es gilt
fuer beide Formen. Wer eine einzelne Bewegung anders sieht, nimmt sie an der
Buchung heraus oder gibt ihr eine eigene Kategorie – dieselben zwei Felder wie
bisher.

Der Normalfall aendert sich nicht: Ein Uebertrag auf ein Konto ohne Sparziel
ist weiterhin bloss verschobenes Geld.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:53:26 +02:00
ScarriffleandClaude Opus 5 991a8e4fc2 fix: Die Kennung der Bank steht jetzt an der Buchung selbst
Bisher lag sie nur am Eingangs-Eintrag. Das reicht nicht: Der Eintrag ist eine
Zwischenstation und faellt weg, wenn ein Konto geloescht wird oder ein aelteres
Backup ihn nicht kannte. Dann war die Zahlung fuer den naechsten Abruf neu,
obwohl sie laengst gebucht war.

Am schlimmsten bei einem als Uebertrag gebuchten Umsatz: Der ist von der
Aehnlichkeitspruefung ausgenommen, dort griff also gar nichts mehr.

Die Kennung gehoert deshalb an die Buchung (transactions.source_uid, mit der
Quelle davor). Dort ueberlebt sie alles, was mit dem Eingang passiert, und die
Eindeutigkeit haelt die Datenbank selbst durch – nicht erst der pruefende Code
davor. Gestempelt wird an allen drei Buchungswegen: Eingang, Kurzbefehl und
Uebertrag.

Vorhandene Buchungen bekommen die Kennung nachgetragen, solange ihr
Eingangs-Eintrag noch da ist.

Ohne Kennung – von Hand erfasst, aus einer CSV-Datei – bleibt es beim
bisherigen Schutz ueber die Aehnlichkeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:59:43 +02:00
GuidoandClaude Opus 5 175d9f50e0 feat: Sagen, wenn Oberflaeche und Server auseinanderlaufen
Ein "npm run build" allein startet den Dienst nicht neu. Die Oberflaeche
ruft dann Endpunkte auf, die es serverseitig noch gar nicht gibt - und das
sieht aus wie ein Fehler in der Funktion, nicht wie ein halbes Update.
Genau daran ging gerade eine Stunde Fehlersuche drauf: Die Aufstellung
eines Ausgaben-Anteils blieb leer, weil der Server den Endpunkt nicht
kannte.

Vite setzt den Commit beim Bauen ins Bundle, die Oberflaeche vergleicht
ihn beim Start mit dem, den der Server unter /api/version meldet. Weichen
sie ab, steht der Hinweis samt Kommando ueber jeder Seite. Kennt eine der
beiden Seiten ihren Stand nicht, schweigt der Hinweis - falscher Alarm
waere schlimmer als keiner.

Beide Richtungen im Browser geprueft: bei verschiedenen Staenden erscheint
er, bei gleichen nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:28:05 +02:00
GuidoandClaude Opus 5 07ff0f2f8d fix: Ein gescheiterter Abruf sah aus wie "keine Ausgaben"
Die Aufstellung eines Ausgaben-Anteils schluckte jeden Fehler und zeigte
stattdessen "Noch keine Ausgaben in diesem Monat" mit einer Summe von
0.00 - waehrend die Karte daneben einen Betrag nannte. Damit war der
Widerspruch, den das Fenster aufloesen sollte, groesser als vorher.

Jetzt steht dort, was schiefging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:20:07 +02:00
GuidoandClaude Opus 5 ee36dae34a feat: Zeigen, welche Buchungen in einem Ausgaben-Anteil stecken
Eine Zahl, die man nicht aufschluesseln kann, muss man glauben. Genau das
war der Streitpunkt: Die Karte nannte fuer Lebensmittel einen Betrag, und
es gab keine Moeglichkeit nachzusehen, welche Zahlungen darin einbezogen
sind und welche nicht.

Jetzt oeffnet ein Klick auf einen Anteil - in der Liste wie in der
Legende des Kreises - die Buchungen dahinter: Datum, Empfaenger, Konto,
Betrag. Die Liste kommt aus derselben Stelle, die auch die Summe bildet
(`budget.expense_rows`), sie kann also gar nicht etwas anderes zeigen als
das, was gezaehlt wurde. Ein Test haelt das fest.

Steht eine Buchung darin, obwohl ihr Datum ausserhalb des Zeitraums
liegt, traegt sie die Kalendermarke: Dann wurde ihr Budget-Monat von Hand
gesetzt, und das ist der Grund.

Nebenbei: Dialoge haengen jetzt am Seitenkoerper. Kacheln der Uebersicht
werden per `transform` positioniert - damit war die Kachel der
Bezugsrahmen fuer `position: fixed`, und der Dialog sass eingeklemmt
darin statt ueber der Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:10:19 +02:00
GuidoandClaude Opus 5 0b19e07ce0 feat: Zu welchem Budget-Monat eine Buchung gehoert
Bisher entschied allein das Datum. Bei einem Budget-Monat, der nicht am
Ersten beginnt, faellt das auf: Die Zahlung geht am 24. ab und gehoert
doch zum naechsten Monat, die Rechnung wird verspaetet beglichen und
gehoert zum letzten.

Neu traegt jede Buchung einen Budget-Monat. Leer heisst weiterhin "aus dem
Datum" - der Normalfall, und er bleibt es. Wird einer von Hand gesetzt,
zaehlt die Buchung nur noch dort und in ihrem eigenen Monat NICHT mehr,
sonst stuende sie zweimal da.

Das Aendern liegt bewusst hinter einem Klick und hinter Pfeilen statt
hinter einer Auswahlliste: Es ist der Ausnahmefall, nicht der Normalfall,
und soll sich auch so anfuehlen. Waehlt man genau den Monat, der ohnehin
gilt, bleibt die Buchung automatisch - verschiebt sich das Datum spaeter,
wandert sie mit. In der Liste zeigt eine Marke den Monat, aber nur, wenn
jemand eingegriffen hat.

Der Server nimmt drei Monate in jede Richtung an. Ein Vertipper wie
2019-03 wird abgelehnt: Er liesse die Buchung aus jeder Auswertung
verschwinden, ohne dass irgendwo etwas fehlt.

Geprueft im Browser gegen eine Wegwerf-Datenbank mit Stichtag 25: Die
Karte summiert 25.08.-24.09., eine verschobene Buchung verlaesst diesen
Zeitraum und kehrt beim Zuruecknehmen zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:57:16 +02:00
GuidoandClaude Opus 5 bd79b83339 feat: Zeitraum an der Karte, und eine schaerfere Suche nach Dubletten
Zwei Dinge, die zusammen die Frage beantworten "zaehlt das wirklich ab
meinem Stichtag, und sind das wirklich zwei Zahlungen?".

Die Karte Ausgaben-Anteile nennt jetzt ihren Zeitraum. Gerechnet hat sie
schon immer ueber den eingestellten Budget-Monat (25.08.-24.09. bei
Starttag 25); nachvollziehen liess sich das nur nicht, weil die Karte
nirgends sagte, worueber sie summiert.

Die Dublettensuche vergleicht nicht mehr stur ueber den Text. Ein krummer
Betrag wie 10.43 kommt an einem Tag kaum zweimal vor - auch wenn zwei
Quellen ihn verschieden benennen, und genau daran scheiterte der
Textvergleich. Bei einem runden Betrag (Vielfaches von 50 Rappen) muss der
Text weiter passen, sonst waeren zwei Zwanziger am selben Tag ein Fund.

Und was die Bank selbst als verschieden gemeldet hat, gilt als geklaert:
Stammen alle Zeilen aus demselben Bankkonto und tragen verschiedene
Kennungen, verschweigt die Karte sie. Das sind die Revolut-Aufrundungen,
die echt sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:39:08 +02:00
GuidoandClaude Opus 5 335a7d55c8 feat: Gleich aussehende Buchungen stehen in der Arbeitsliste
Bisher fand man Dubletten nur zufaellig beim Durchscrollen der Buchungsliste –
oder ich habe ein Skript dafuer geschrieben, und der Nutzer musste sich auf
einen Server einloggen, um seine eigenen Zahlen zu reparieren. Beides ist
falsch.

Die Arbeitsliste zeigt sie jetzt: gleiches Konto, gleicher Tag, gleicher
Betrag, gleicher Text. Je Zeile steht die Kennung der Bank dabei, und die
entscheidet die Frage – dieselbe Kennung heisst sicher dieselbe Zahlung,
verschiedene heissen, die Bank hat sie als verschieden gemeldet.

Entschieden wird bewusst NICHTS von selbst. Drei Aufrundungen ueber 1.00 am
selben Tag sind echt, drei gleiche Einkaeufe koennen es auch sein; das weiss
nur der Nutzer. Certain meldet und laesst loeschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:32:40 +02:00
GuidoandClaude Opus 5 63056ae76a feat: Ringdiagramm faerbt Legende und Mitte nach Budget
Eingefaerbt war bisher nur die Listen-Darstellung der Ausgaben-Anteile. In der
Kreis-Darstellung blieben die Betraege der Legende grau, und die Summe in der
Mitte sagte gar nichts ueber den Stand.

Jetzt tragen beide die Aussage: gruen im geplanten Rahmen, rot darueber. Faehrt
man ein Segment an, zeigt die Mitte dessen Betrag – dann gilt dort das Budget
dieser Kategorie, sonst das des Ganzen.

Fuer die Mitte liefert der Server jetzt auch das Geplante ueber ALLE
Ausgaben-Einheiten, nicht nur ueber die mit Ausgaben: Der Monatsplan umfasst
auch, wofuer noch nichts ausgegeben wurde.

Ohne Plan bleibt alles, wie es war. Der Donut faerbt nur, wenn ein Segment die
Angabe mitbringt – die Budget-Karte, die dieselbe Komponente benutzt, bleibt
also unberuehrt.

Der Test hat dabei gleich einen Fehler gefunden: ``_units`` ist ein Generator,
und ich lief zweimal darueber – beim zweiten Mal war er leer und das Geplante
immer 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:13:49 +02:00
GuidoandClaude Opus 5 4d3b9a8207 feat: Auch der Balken der Ausgaben-Anteile faerbt nach Budget
Der Balken IST die Ausgabe – er soll die Aussage mittragen, damit man sie
sieht, ohne die Zahl zu lesen. Gruen im geplanten Rahmen, rot darueber.

Ohne Plan behaelt er die Kategoriefarbe: Da gibt es nichts zu ueberschreiten,
und die Karte stuende sonst fast durchgehend auf einer Farbe, die nichts
bedeutet.

Der Punkt links bleibt in jedem Fall in der Kategoriefarbe – sonst liessen
sich die Zeilen nicht mehr auseinanderhalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:09:14 +02:00
GuidoandClaude Opus 5 5e70644f73 feat: Ausgaben-Anteile faerben nach Budget
Die Karte zeigte nur, wie sich die Ausgaben verteilen – nicht, ob sie im
Rahmen liegen. Der Betrag ist jetzt gruen, solange er unter dem Geplanten
bleibt, und rot darueber. Dieselben Farben wie ueberall sonst fuer Einnahme
und Ausgabe.

Ohne Plan bleibt der Betrag gedeckt. "Ueber Budget" waere sonst die Aussage
ueber jede Kategorie, fuer die nie eine Vorgabe gemacht wurde – die Karte
stuende dann fast durchgehend auf Rot und sagte nichts mehr.

Das Geplante kommt aus derselben Quelle wie im Budget-Fortschritt, also
einschliesslich eines Monats-Werts, der die Vorlage uebersteuert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:05:28 +02:00
GuidoandClaude Opus 5 1509695760 fix: Ein Umsatz unter neuer Kennung kam ein zweites Mal herein
Der Bankabruf nahm die ERSTE Kennung, die ein Umsatz mitbringt – meist die
Referenz der Bank. Vergibt die Bank sie beim Buchen neu (aus vorgemerkt wird
gebucht), ist es fuer Certain ein anderer Umsatz, und dieselbe Zahlung steht
zweimal da. Genau so sind zwei Mietbuchungen ueber 288 Euro am selben Tag
entstanden.

Zwei Aenderungen:

- Geprueft wird gegen ALLE Kennungen des Umsatzes, nicht nur gegen eine. Damit
  treffen auch Eintraege, die frueher unter einer anderen abgelegt wurden.
- Gespeichert wird die bestaendigste: die eigene Kennung der Schnittstelle
  bleibt gleich, wenn die Bank ihre Referenz austauscht.

Das ist eine von vier Stellen, an denen Certain Dubletten erkennt – neben dem
Import-Schluessel, dem unscharfen Eingangs-Abgleich und dem
Ueberschneidungsschutz. Diese vier gehoeren zusammengefuehrt; das ist ein
eigener Umbau und nicht Teil dieser Korrektur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:51:50 +02:00
GuidoandClaude Opus 5 67a32bdda9 feat: Aufschluesselung zeigt, was der Stichtag ausschliesst
Buchungen bis einschliesslich des Stichtags zaehlen nicht in den Kontostand –
sie stecken schon im Anfangsbestand. In der Liste stehen sie aber, und in den
Auswertungen zaehlen sie mit. Das ist richtig so, war in der Oberflaeche aber
nirgends zu sehen: Der Unterschied zwischen dem, was man sieht, und dem, was
gerechnet wird, liess sich nicht erklaeren.

Ein Konto, dessen Stichtag nach dem Beginn des eingelesenen Auszugs liegt,
zeigt deshalb einen Kontostand, der zu den sichtbaren Buchungen nicht passt –
und niemand kaeme darauf, warum.

Die Rechnung nennt jetzt Stichtag, Anzahl und Summe der ausgeschlossenen
Buchungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:41:22 +02:00
GuidoandClaude Opus 5 7c0d13cc8a feat: Kontostand aufschluesseln – und der Angleich-Knopf bleibt
Zwei Sachen aus derselben Wurzel: Wenn ein Saldo nicht stimmt, sieht man nicht,
warum, und kann auch nichts dagegen tun.

Ein Klick auf den Kontostand zeigt jetzt die Rechnung: Anfangsbestand,
Einnahmen, Ausgaben, Uebertraege raus und rein – jeweils mit der Anzahl der
Buchungen – und was daraus wird. Darunter der Saldo der Bank und, einzeln
aufgefuehrt, alle Uebertraege des Kontos. Genau die sind die haeufigste
Ursache und in einer langen Buchungsliste am schwersten zu ueberblicken.

Rechnung und Kontenliste kommen aus EINER Quelle: ``account_balance_cents``
liest jetzt aus der Aufschluesselung. Sonst koennte die Anzeige etwas anderes
behaupten als der Saldo, und man suchte den Fehler im Falschen.

Ausserdem war der Knopf "Saldo der Bank uebernehmen" verschwunden, sobald die
Bank nur einen verfuegbaren Saldo meldet. Das war zu weit gedacht: Nicht
vergleichbar heisst nicht, dass man nicht angleichen duerfte. Der Knopf ist
zurueck, und die Nachfrage sagt in dem Fall, worauf man sich einlaesst – der
verfuegbare Saldo enthaelt Vorgemerktes, das spaeter noch als Buchung kommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:33:27 +02:00
GuidoandClaude Opus 5 7fe2678192 feat: Ein verknuepftes Paar steht als EINE Zeile in der Buchungsliste
Ein Uebertrag ist eine Bewegung, keine zwei. In der Liste standen aber beide
Seiten untereinander, jede mit der Marke des Gegenkontos – man musste sie
selbst zusammensetzen, und es sah aus wie eine Dublette.

Liegen beide Seiten in der Liste, wird die Zugangs-Seite ausgeblendet und die
Abgangs-Seite zeigt "Revolut CHF → Sparen Pocket" – so wie ein echter
Uebertrag. Ueber zwei Waehrungen stehen beide Betraege untereinander
("CHF 400.00" / "→ 425,32 €"); nebeneinander passen sie nicht in die Spalte.

In der Datenbank aendert sich nichts: Beide Buchungen bleiben stehen, mit ihren
Import-Schluesseln und ihrer Stapel-Zugehoerigkeit. Das ist die tragende
Eigenschaft der Verknuepfung, und sie bleibt.

Ist nur eine Seite geladen – etwa weil nach einem Konto gefiltert wird –,
bleibt sie einzeln stehen und traegt weiter die Marke mit dem Gegenkonto.
Sonst verschwaende dort eine Buchung aus der Liste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:18:22 +02:00
GuidoandClaude Opus 5 e0d8882851 fix: Automatischer Abruf lief gegen die Abfragegrenze der Banken
"Weniger als 15 Minuten lassen die Banken nicht zu" stand als Hinweis im
Formular. Das war erfunden. PSD2 verpflichtet die Banken zu VIER
unbeaufsichtigten Abfragen je Konto und Tag – haeufiger duerfen sie ablehnen,
und N26 tut es: "Too many requests", und der Abruf scheitert, bis das
Zeitfenster weiterrueckt.

Bei zehn angebundenen Konten und einem Takt von 15 Minuten waren es tausende
Anfragen am Tag. Der Takt betraegt jetzt mindestens sechs Stunden, also genau
die zugesicherten vier je Tag. Bestehende, zu knappe Einstellungen werden beim
Faelligkeitspruefen mitgebremst – sonst liefe eine gespeicherte 15 weiter
dagegen.

Von Hand abrufen bleibt unbegrenzt: Dabei sitzt der Nutzer davor, und fuer
diesen Fall gilt die Grenze nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:11:35 +02:00
GuidoandClaude Opus 5 6ed91ddd58 fix: Verknuepfte Buchungen brauchen keine Kategorie mehr
Eine verknuepfte Buchung gehoert zu einem Uebertrag und zaehlt in keiner
Auswertung. Trotzdem stand sie unter "Zu erledigen" und wurde in der
Buchungsliste nach einer Kategorie gefragt – Arbeit, die nichts bewirkt, und
ein falsches Signal: Sie sah aus, als fehle noch etwas.

Der Filter fuer offene Buchungen laesst verknuepfte jetzt aus, und in der
Buchungsliste steht bei ihnen ein Strich statt der Auswahl – so wie bei einem
echten Uebertrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:07:29 +02:00
GuidoandClaude Opus 5 bb748cc092 feat: Certain fragt in der Buchungsliste nach, ob es ein Uebertrag ist
Die Erkennung gab es laengst – aber nur auf der Seite "Zu erledigen", in einer
Karte, die man erst finden muss. Wer in der Buchungsliste steht und die zweite
Haelfte eines Uebertrags sieht, greift stattdessen zum ↔-Knopf und legt einen
zweiten Uebertrag an. Genau so ist heute eine Zahlung doppelt verschoben
worden.

Jetzt steht die Frage in der Zeile: "Uebertrag von/nach Revolut CHF?" mit
Verbinden und Ignorieren. Ignorieren wird gemerkt, sonst fragte Certain bei
jedem Laden erneut.

Dafuer gibt es die Suche jetzt auch als reines Nachschlagen: ``finde`` mit
``nur_vorschlaege`` verknuepft nichts, und dahinter liegt GET
/uebertraege/vorschlaege. Eine Ansicht, die bloss fragt, darf beim Hinsehen
nichts veraendern – sonst entschiede das Anschauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:03:25 +02:00
GuidoandClaude Opus 5 7b3ed3e63c fix: Zweiter Uebertrag fuer dieselbe Bewegung wurde nicht erkannt
Die Sperre sah auf dem Zielkonto nur nach Ausgaben und Einnahmen. Steht dort
schon ein UEBERTRAG, der dasselbe Geld zwischen denselben beiden Konten
bewegt, fand sie nichts – und der zweite ging durch.

Genau das passiert im Alltag: Die eine Seite kommt von der Bank, die andere
aus der CSV-Datei, und beide sehen einzeln nach einem Uebertrag aus. Wer beide
umwidmet, hat das Quellkonto doppelt belastet und das Ziel doppelt
gutgeschrieben – bei 1'575 macht das 3'150 Unterschied auf zwei Konten.

Jetzt wird zuerst geprueft, ob zwischen denselben beiden Konten schon ein
Uebertrag ueber denselben Betrag in derselben Woche steht. Die Richtung ist
dabei egal, ein Uebertrag hat kein Vorzeichen. Dieser Fall laesst sich auch
nicht mit "trotzdem" uebergehen: Dasselbe Geld zweimal zu verschieben ist
keine Ansichtssache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 12:58:37 +02:00
GuidoandClaude Opus 5 c1561acdce fix: Vier Ursachen, warum Saldo und Umsaetze auseinanderliefen
Die staendige Meldung "weicht ab" hatte mehrere Quellen, und eine davon war
ueberhaupt keine Abweichung.

1. Ein VERFUEGBARER Saldo enthaelt Vorgemerktes. Certain bucht Vorgemerktes
   bewusst nicht – es kaeme spaeter mit anderer Kennung ein zweites Mal. Beides
   zu vergleichen musste also dauerhaft danebengehen. Verglichen wird jetzt nur
   noch ein GEBUCHTER Saldo (CLBD, ITBD, PRCD); sonst steht dort "verfuegbarer
   Saldo, nicht vergleichbar" und keine Abweichung.

2. Die Saldi wurden VOR den Umsaetzen geholt und sofort geschrieben. Brach der
   Umsatz-Abruf danach ab, stand ein aktueller Saldo neben altem Bestand – und
   die Anzeige meldete eine Abweichung, die nur aus der Reihenfolge kam. Sie
   kommen jetzt zuletzt. Ohne Kontozuordnung weiterhin zuerst: Dort gibt es
   keine Umsaetze zu holen, und bei mehreren gleich aussehenden Pockets ist der
   Betrag das Einzige, woran man sie unterscheidet.

3. Jeder weitere Abruf griff nur drei Tage hinter den letzten zurueck. Banken
   buchen mitunter spaeter zurueck; was einmal ausserhalb lag, kam nie wieder.
   Jetzt sieben Tage – doppelt holen kostet nichts, die Kennung des Umsatzes
   faengt es exakt ab.

Damit ist die Anzeige nicht mehr nur ehrlicher, sondern zeigt auch wieder auf
etwas: Steht dort eine Abweichung, ist sie echt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:44:01 +02:00
GuidoandClaude Opus 5 4000082df4 fix: Dieselbe Zahlung ueber zwei Bankzugaenge kam doppelt herein
Wer dieselbe Bank zweimal verbindet, bekommt fuer dieselbe Zahlung zwei
verschiedene Kennungen – Enable Banking fuehrt eine Bank je Land getrennt.
Genau das passiert bei Revolut: Ueber den DE-Eintrag kommen drei Konten, ueber
den LT-Eintrag zusaetzlich die Pockets, also verbindet man beide. Zeigen dann
zwei Bankkonten auf dasselbe Konto in Certain, holt jeder Zugang dieselben
Umsaetze, und der Duplikatschutz kann sie nicht als dieselben erkennen.

Drei Aenderungen:

- Ein Konto in Certain wird nur noch von EINEM Bankkonto gefuellt. Ordnet man
  ein zweites zu, wird die alte Zuordnung geloest. Sie ist ohnehin nie richtig,
  und stillschweigend doppelt zu buchen ist schlimmer als eine geloeste
  Zuordnung.
- Die Ausnahme im Ueberschneidungsschutz gilt nur noch fuer DASSELBE Bankkonto.
  Dort entscheidet die Kennung der Bank, und die ist exakt – zwei Aufrundungen
  ueber 1.00 duerfen sich nicht verschlucken. Buchungen aus einem ANDEREN
  Zugang zaehlen jetzt mit.
- Das Aufraeumskript kennt den Fall: Findet es in einer Gruppe (Konto, Art,
  Tag, Betrag) Buchungen aus zwei Zugaengen, bleibt die Fassung EINES Zugangs
  stehen. Aufrundungen aus einem einzigen Zugang bleiben unangetastet, auch
  wenn sie gleich aussehen.

An nachgestellten Daten geprueft: Aus -579.50 wird -290.50; die drei gleichen
Aufrundungen aus einem Zugang bleiben alle stehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:39:57 +02:00
GuidoandClaude Opus 5 a159546218 feat: Rueckkehr von der Bank sagt, was ankam und was liegen blieb
"3 Konten gefunden" beantwortet die entscheidende Frage nicht: Hat die Bank nur
drei geschickt, oder hat Certain die uebrigen verworfen? Der Server rechnet
beides laengst aus, die Seite zeigte es nur, wenn ueberhaupt kein Konto ankam.

Jetzt steht immer dabei, wie viele die Bank geschickt hat. Und Konten, die
Certain nicht uebernehmen konnte, werden nicht mehr bloss gezaehlt, sondern
mitsamt dem gezeigt, was von ihnen ankam – uebersprungen werden sie, weil die
Kennung fehlt, mit der sich Salden und Umsaetze holen lassen.

Anlass: Fuenf Revolut-Pockets sind im Control Panel freigeschaltet und wurden
bei der Autorisierung ausdruecklich mitgewaehlt, kommen in Certain aber nicht
an. Ohne diese Zahlen laesst sich nicht sagen, wo sie verloren gehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:12:45 +02:00
GuidoandClaude Opus 5 4c2e72cb20 fix: Bankanbindung gliedern – und ein Platzhalter, der nicht ersetzt wurde
Im Kopf jeder Verbindung stand woertlich "zuletzt {{when}}": Der Text erwartet
`when`, uebergeben wurde `datum`.

Die Gliederung lieh sich die Klassen des Postfachs – gestrichelte Trennlinien,
sonst nichts. Damit war weder zu sehen, wo eine Bank aufhoert, noch welcher
Knopf zu welchem Konto gehoert, und das Suchfeld fuer eine neue Bank
verschwamm mit den Angaben der schon verbundenen.

Jetzt: Jede Verbindung ist ein umrandeter Block mit Kopfzeile. Jedes Konto
darin hat eine eigene linke Kante – die bindet seine Knoepfe sichtbar an es.
Die Knoepfe der VERBINDUNG (alle Konten, Salden, trennen) stehen ohne
Einrueckung hinter einer Trennlinie, gehoeren also erkennbar zur Bank und
nicht zum letzten Konto. "Weitere Bank verbinden" ist mit eigener Ueberschrift
abgesetzt.

Nebenbei brach "Minuten (0 = aus)" neben dem Zahlenfeld um und las sich wie
ein Fehler; die Klammer steht ohnehin im Hinweis darunter.

Im Browser nachgemessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:04:32 +02:00
GuidoandClaude Opus 5 385352974c fix: Abruf ueber schon eingelesene Monate buchte alles doppelt
"Aelteres nachholen" greift 90 Tage zurueck – mitten in Monate, die schon per
CSV eingelesen sind. Genau davor schuetzt sonst das Startdatum, und der Knopf
haengt es aus. Der Duplikatschutz faengt das nicht: Sein Schluessel enthaelt
den Empfaengertext, und der lautet bei der Bank anders als im Auszug. Ergebnis:
jede Zahlung des Zeitraums zweimal.

Das war mein Fehler – ich habe den Knopf gebaut UND empfohlen, ohne die
Sicherung zu ersetzen, die er aushebelt.

Der Abruf prueft jetzt selbst: Gibt es auf dem Konto schon eine Buchung
gleicher Richtung und gleichen Betrags im Umkreis von drei Tagen, die NICHT
von der Bank kam, ist der Umsatz dieselbe Bewegung und wird uebersprungen.
Jede vorhandene Buchung erklaert dabei hoechstens einen Umsatz – sonst
verschluckte eine Kaffeezahlung alle weiteren desselben Betrags. Die Zahl der
uebersprungenen steht in der Rueckmeldung.

Fuer den bereits angerichteten Schaden gibt es
scripts/raeume_doppelte_bankumsaetze.py: Es findet Bank-Umsaetze, die eine
vorhandene Buchung wiederholen, und entfernt IMMER die von der Bank – die
eigene traegt die Kategorie des Nutzers. Sie wird verworfen, nicht geloescht,
damit kein spaeterer Abruf sie wiederholt. Ohne --wirklich passiert nichts;
mit --wirklich wird zuerst eine Kopie der Datenbank angelegt.

An nachgestellten Daten geprueft: Aus -95.80 wird -48.40, die eigenen
Buchungen samt Kategorie bleiben, der echte neue Umsatz bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:55:51 +02:00
GuidoandClaude Opus 5 12ea391e9f fix: Startdatum lag in der Zukunft – Saldi kamen, Umsaetze nicht
Beim ersten Abruf beginnt Certain am Tag NACH der neuesten vorhandenen Buchung
des Kontos, damit es nicht in schon eingelesene Monate zurueckgreift. Liegt
diese Buchung auf heute, ist der Tag danach morgen – und die Schnittstelle
lehnt das rundweg ab: "date_from can not be in the future".

Der Fehler faellt erst beim Umsatz-Abruf an. Die Saldi sind da schon gelesen
und geschrieben, der Abruf bricht danach ab. Von aussen sieht es genau so aus,
wie der Nutzer es beschrieben hat: Der Kontostand aktualisiert sich, die
Buchungen kommen nie – und die Abweichung bleibt bestehen, obwohl man den
Saldo gerade erst von der Bank uebernommen hat. Genau dieses Uebernehmen legt
naemlich keine Buchung an, aber es gibt fast immer eine von heute.

Das Startdatum wird jetzt auf heute begrenzt. Gegenprobe: Ohne die Begrenzung
verlangt der Abruf den 28.08. an einem 27.08.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:48:03 +02:00
GuidoandClaude Opus 5 b4c90f5221 fix: Bankabruf verschluckte Umsaetze mit wiederkehrendem Betrag
Der Eingangs-Abgleich vergleicht Betrag und Datum mit ein paar Tagen
Spielraum – bewusst unscharf, damit eine per Kurzbefehl erfasste Zahlung nicht
ein zweites Mal ankommt. Er merkt sich dabei, welchen Eintrag er schon
verbraucht hat.

Nur ruft der Bankabruf ihn JE ZEILE einzeln auf, und damit begann diese Menge
jedes Mal von vorn: Derselbe Eintrag liess sich beliebig oft verbrauchen. Bei
Revolut-Aufrundungen – dreimal 1.00 in drei Tagen – kam so nur die erste an,
und der Saldo wich ab, ohne dass irgendwo etwas stand.

Zwei Aenderungen:

- Der Aufrufer kann die Menge jetzt ueber mehrere Aufrufe mitfuehren; der
  Bankabruf haelt eine je Konto.
- Gegen andere BANK-Eintraege wird gar nicht mehr unscharf verglichen. Zwei
  Umsaetze derselben Quelle sind nie Dubletten voneinander – dort entscheidet
  die Kennung der Bank, und die ist exakt.

Dazu ein Weg zurueck: Ein verschluckter Umsatz hinterliess keinen Eintrag, der
naechste Abruf haette ihn also wieder geholt – nur liegt er dann vor dem
Fenster des letzten Abrufs. "Aelteres nachholen" gibt es jetzt auch je Konto,
wie beim Postfach.

Nachgestellt und als Test festgehalten: dreimal 1.00 in drei Tagen, alle drei
kommen an, Saldo -3.00.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:45:38 +02:00
GuidoandClaude Opus 5 f2c1ab7dec feat: Regeln nachtraeglich auf Buchungen ohne Kategorie anwenden
Regeln greifen beim Import. Alles, was vorher hereinkam – oder hereinkam, bevor
es die Regel gab –, bleibt liegen. Bei einer Bankanbindung passiert genau das
dauernd: Die Umsaetze kommen von selbst, die Regel entsteht spaeter. Die
Revolut-Aufrundungen aufs Sparen-Pocket sind der Fall, an dem es aufgefallen
ist.

Der Knopf steht bei der Transfer-Erkennung und zeigt erst, was passieren
wuerde. Uebertrags-Regeln laufen durch denselben Dienst wie jeder andere
Uebertrag – er lehnt ab, wo die Gegenseite schon gebucht ist, und solche
Buchungen bleiben unangetastet und werden mit Grund gemeldet.

Ohne festes Zielkonto wird es aus dem Buchungstext erraten, aber nur bei GENAU
einem Treffer und nur an Wortgrenzen: "Euro" darf nicht "PayPal Europe"
treffen. Zwei Moeglichkeiten heisst raten, und dann passiert nichts.

Angefasst werden nur Buchungen OHNE Kategorie – sonst ueberschriebe der Knopf
stillschweigend, was von Hand zugeordnet wurde. Ein Uebertrag ist kein Kandidat
mehr, ein zweiter Klick verschiebt das Geld also nicht erneut.

Zwei Dinge fielen beim Testen auf und sind mit behoben:

- Ein fehlgeschlagener Uebertrag verwarf per rollback() auch alles, was im
  selben Durchlauf schon gelungen war. Jetzt haelt ein Rettungspunkt je
  Uebertrag den Schaden bei dem einen.
- test_neutral_category nahm VORHANDENE Kategorien und stellte eine davon auf
  "nicht einrechnen". Benutzten andere Buchungen sie, fielen die mit heraus und
  die Rechnung ging um deren Summe daneben – ohne Bezug zum Gepruefften. Der
  Test legt sich seine Kategorien jetzt selbst an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:02:04 +02:00
GuidoandClaude Opus 5 1b47e5abe1 fix: Ohne Anmeldung liessen sich beliebige Dateien vom Server lesen
Die Auslieferung des Frontends nahm den Pfad roh aus der Adresse: "Path /
'../../x'" verlaesst den Ordner klaglos, und die Route liegt VOR jeder
Anmeldepruefung. Gegen die eigene Instanz nachgestellt – roh und
prozentkodiert kam beides Mal die Datei heraus:

    GET /../../GEHEIM.txt            -> "geheim"
    GET /../../backend/app/config.py -> der Quelltext

Damit war alles lesbar, was der Dienst lesen darf: Quelltext, Konfiguration,
und mit der passenden Zahl von Ebenen die Datenbank samt verschluesseltem
Postfach-Passwort und Bank-Schluessel. Der Pfad wird jetzt aufgeloest und
muss im Auslieferungs-Ordner liegen.

Zweitens prueft der Postfach-Abruf jetzt das Zertifikat. Ohne eigenen
SSL-Kontext tut imaplib das NICHT – die Verschluesselung schuetzt dann vor
niemandem, der sich dazwischenhaengt, und das Postfach-Passwort geht mit.
Gilt fuer beide Wege, SSL und STARTTLS.

Drittens warnt der Start, wenn das Sitzungs-Cookie ohne "Secure" ausgeliefert
wird. Die Vorgabe bleibt bewusst aus: Sie umzustellen sperrt jeden aus, der
Certain im Heimnetz ueber HTTP aufruft – und ein Ausgesperrter saehe die
Ursache nirgends.

Die Befunde stammen aus dem /security-review dieser Sitzung und waren seither
offen. Sie wiegen schwerer, seit die Anwendung fuer die Bankanbindung unter
einer oeffentlichen HTTPS-Adresse erreichbar sein muss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:07:42 +02:00
GuidoandClaude Opus 5 d88967d77d fix: Budget-Monat war bei Starttag 29 bis 31 nicht schluessig
Der Starttag wird auf den letzten Tag des Monats gekappt – einen 31. Februar
gibt es nicht. period_bounds tat das, period_of nicht: Es verglich stur den
Tag des Monats. Damit fiel der 28. Februar bei Starttag 31 in einen Monat, der
vom 31. Januar bis zum 27. Februar reicht – der Tag lag also in keiner
Auswertung.

Betroffen waren elf Tage in vier Jahren, alle am Ende kurzer Monate, und nur
bei Starttag 29, 30 und 31. Bei allen anderen stimmte es.

Zwei Tests halten es fest: Jeder Tag von 2024 bis 2027 muss bei JEDEM Starttag
in dem Monat liegen, den period_of ihm zuweist – und die Monate muessen
lueckenlos aneinanderstossen, keiner doppelt, keiner dazwischen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:37:32 +02:00
GuidoandClaude Opus 5 e2428bbabd fix: Herkunft als Pille im vorhandenen Badge-Stil
Der Kasten in Titelgroesse war zu laut fuer eine Angabe, die nur eine
Rueckfrage beantwortet. Statt einen zweiten Stil zu erfinden, nimmt sie jetzt
".badge" – dieselbe Pille wie ueberall sonst im Projekt: abgerundet, weich
gefuellt, 11 statt 14 Punkt.

In der Datei steht nur noch, wie sie sich in der Titelzeile verhaelt: nicht
schrumpfen, nicht umbrechen. Eng wird zuerst der Titel, denn ein halbes
"Datei-Imp…" nuetzt niemandem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:44:35 +02:00
GuidoandClaude Opus 5 d6ae963241 fix: Herkunft steht neben dem Titel, nicht kleingedruckt darunter
Die zweite Zeile gehoert dem Verwendungszweck. Alles Neue dorthin zu stapeln
macht die Zeilen hoch und den Blick eng – und das Kennzeichen war zusaetzlich
kleiner gesetzt als alles andere.

Jetzt steht es neben dem Titel und in dessen Groesse. Wird es eng, kuerzt der
Titel und das Kennzeichen bleibt lesbar; die Mindestbreite der Spalte steigt
dafuer von 150 auf 190 Punkte. Die Tabelle passt damit weiterhin ohne
waagerechtes Scrollen in ein Fenster von 870 Punkten.

Im Browser an beiden Enden nachgemessen: bei voller Breite genug Platz fuer
beides, bei Mindestbreite kuerzt der richtige Teil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:33:55 +02:00
GuidoandClaude Opus 5 61dc6c7f0b feat: Mail-Abruf sagt, was mit den Treffern passiert ist – und jede Buchung,
woher sie kommt

Die Meldung nach einem Abruf zaehlte geprueft, neu und unklar – aber nicht
"schon bekannt". Wurden alle Treffer beim vorigen Abruf schon uebernommen,
stand da "0 neu", und es sah aus, als waere nichts passiert, obwohl die
Eintraege im Eingang liegen. Die Zahl steht jetzt dabei.

Und die uebergangenen Mails wurden aufgelistet, die unklaren nicht: nur eine
Zahl, ohne zu sagen, welche Mail gemeint ist oder was ihr fehlt. Jetzt stehen
sie mit Grund da – kein Konto zugeordnet, kein Betrag erkannt, Richtung unklar,
Zahlung noch nicht ausgefuehrt.

Ausserdem traegt jede Buchung jetzt ihre Herkunft: Bank, Mail, Kurzbefehl,
Datei-Import oder von Hand. In der Buchungsliste steht sie als kleines
Kennzeichen unter dem Text. Ermittelt wird sie mit einer Sammelabfrage, nicht
je Zeile.

Bei einem Uebertrag zeigen zwei Eingangs-Eintraege auf dieselbe Buchung; die
Gegenseite hat Certain selbst angelegt, gemeint ist die echte Quelle. Genau
dafuer gibt es einen Test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:11:18 +02:00
GuidoandClaude Opus 5 1084cf7c17 feat: Postfach zeigt Takt und Abrufzeitpunkt in der Uebersicht
Der Takt des automatischen Abrufs gab es laengst – aber nur im
Bearbeiten-Formular. In der Uebersicht war deshalb nicht zu sehen, ob
ueberhaupt automatisch abgerufen wird, und es sah aus, als gaebe es die
Einstellung nicht. Bei der Bankanbindung steht sie direkt in der Karte; jetzt
auch hier: "alle 15 Minuten" oder "nur auf Knopfdruck".

Und "zuletzt abgerufen" stand ohne Zeitpunkt da, sagte also nichts. Jetzt mit
Datum und Uhrzeit – die Uhrzeit ist noetig, weil der Abruf meist am selben Tag
lief.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:06:00 +02:00
GuidoandClaude Opus 5 60b8178c57 fix: Mails vor der Einrichtung waren fuer immer ausserhalb des Suchfensters
Der Zeitstempel des letzten Abrufs wird auch dann gesetzt, wenn ein Abruf
nichts fand – und danach sucht Certain nur noch ab gestern. Wer das Postfach
einrichtet, nachdem die Mails schon da waren, findet sie nie: Der erste,
ergebnislose Abruf schiebt das Fenster an ihnen vorbei.

Jetzt gibt es "Aelteres nachholen": Es sucht die letzten 90 Tage und uebergeht
den Zeitstempel. Dass dabei nichts doppelt ankommt, sichert wie bisher die
Message-ID.

Ausserdem sagt die Meldung jetzt, AB WANN gesucht wurde. Bisher stand dort
"Stimmt der Ordner?" – die Frage war fast immer die falsche.

Und ein latenter Fehler, der im August nicht auffaellt: Das Suchdatum wurde mit
strftime("%b") gebaut, also mit dem Monatskuerzel in der Sprache des Servers.
IMAP verlangt die englischen; auf einem deutschen System haette die Suche im
Maerz, Mai, Oktober und Dezember nichts gefunden. Die Kuerzel stehen jetzt fest
im Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:59:08 +02:00
GuidoandClaude Opus 5 ca63fef5c4 feat: Doppelte Uebertraege reparieren, nicht nur melden
Das Pruefskript liess die Arbeit beim Nutzer, obwohl die Entscheidung aus der
Datenbank folgt. Das Reparaturskript trifft sie und fuehrt sie aus:

  Beide Konten angebunden   Die Bank liefert beide Seiten selbst. Der Uebertrag
                            wird in die Buchung zurueckverwandelt, aus der er
                            entstand – WELCHE das war, sagt der Eingangs-
                            Eintrag, der auf ihn zeigt. Danach stehen beide
                            Seiten wieder da und lassen sich verbinden.

  Gegenkonto nicht abgerufen  Der Uebertrag ist richtig, die zusaetzliche
                            Buchung ueberzaehlig und wird geloescht. Ihr
                            Eingangs-Eintrag wird verworfen statt geloescht,
                            sonst braechte ihn der naechste Abruf wieder.

Ohne --wirklich passiert nichts, es wird nur gezeigt. Mit --wirklich legt es
zuerst eine Kopie der Datenbank an, ueber die SQLite-Sicherung und nicht als
Dateikopie – sonst fehlte das WAL.

Uebergangen statt geraten wird alles Uneindeutige: von Hand angelegte
Uebertraege, solche ueber zwei Waehrungen, nur einseitig angebundene Paare und
Uebertraege mit Dubletten auf BEIDEN Seiten.

An nachgestellten Daten geprueft: Aus Lohnkonto -1'575 / Revolut 2'250 / Pocket
450 wird -1'575 / 1'125 / 450 – also genau das, was tatsaechlich passiert ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:52:36 +02:00
GuidoandClaude Opus 5 0288b2be9f feat: Pruefskript sagt, WELCHE der beiden Zeilen weg muss
Es liess die Entscheidung beim Nutzer, obwohl die Datenbank sie hergibt: In
bank_account_links steht, welche Konten Certain selbst abholt. Sind beide
angebunden, liefert die Bank beide Seiten und der Uebertrag ist falsch. Wird
das Gegenkonto nicht abgerufen, ist der Uebertrag richtig und die zusaetzliche
Buchung ueberzaehlig. Das steht jetzt je Fall dabei.

Ausserdem war die Zusammenfassung irrefuehrend: Sie addierte Betraege in beide
Richtungen. Eine zusaetzliche Gutschrift macht das Konto zu hoch, eine
zusaetzliche Belastung zu niedrig – aus '2'026 zu viel' werden so '1'125 zu
hoch'.

Dazu zwei Tests zu der Frage, ob der naechste Abruf alles wieder anlegt: Nein.
Der Umsatz wird an der Kennung der Bank wiedererkannt, unabhaengig davon, was
aus der Buchung geworden ist, und ein spaeterer CSV-Auszug faellt ueber den
Eingangs-Abgleich. Aufraeumen haelt also.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:40:38 +02:00
GuidoandClaude Opus 5 0cd0aab90d feat: Doppelt gebuchte Uebertraege auffindbar machen
Solange beim Anlegen eines Uebertrags nicht geprueft wurde, ob die Gegenseite
schon gebucht ist, konnte dieselbe Bewegung zweimal auf einem Konto landen.
Das ist seit dem vorigen Commit unterbunden – die Daten von vorher bleiben
aber, wie sie sind.

Das Skript findet sie: Zu jedem Uebertrag sieht es auf BEIDEN Konten nach, ob
dort zusaetzlich eine passende Buchung steht (gleiche Tage, passender Betrag,
ueber Waehrungen hinweg mit Spielraum), und rechnet zusammen, wieviel dann zu
viel dasteht.

Es aendert NICHTS und oeffnet die Datenbank nur lesend. Aufraeumen bleibt Sache
des Nutzers, denn welche der beiden Zeilen falsch ist, haengt daran, ob Certain
das Gegenkonto selbst abholt – das kann das Skript nicht wissen, und ein
Aufraeumskript hat hier schon einmal Richtiges mit weggeraeumt. Beide Faelle
stehen als Anleitung dabei.

Dazu ein Regressionstest fuer genau den gemeldeten Fall: Gutschrift steht schon
auf dem Zielkonto, die Belastung soll zum Uebertrag werden – das wird jetzt
abgefangen, und der Saldo des Zielkontos bleibt unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:28:12 +02:00
GuidoandClaude Opus 5 6cd1c100c0 fix: Uebertrag pruefen, ob die Gegenseite schon gebucht ist
Ein Franken-in-Euro-Tausch stand zweimal da: die CHF-Seite als Uebertrag nach
Revolut Euro, die EUR-Seite weiterhin als eigene Einnahme. Revolut Euro war
damit doppelt gutgeschrieben. Drei Fehler auf einmal.

Erstens prueft "zum Uebertrag machen" jetzt, ob die Buchung bereits mit ihrer
Gegenseite VERBUNDEN ist. Dann sind beide Seiten gebucht und zaehlen schon in
keiner Auswertung; ein Uebertrag bewegt das Geld auf dem Zielkonto ein zweites
Mal. Das laesst sich auch nicht mit "trotzdem" wegdruecken – es ist nicht
Ansichtssache.

Zweitens wird auf dem Zielkonto nach einer passenden gebuchten Gegenseite
gesehen, auch ohne Verknuepfung: gleiche Tage, passender Betrag, ueber
Waehrungen hinweg mit Spielraum. Findet sich eine, kommt die Warnung samt dem
richtigen Weg – die beiden verbinden statt einen Uebertrag anzulegen. Der
Treffer ist unscharf, deshalb hier mit "trotzdem" uebergehbar.

Drittens der beobachtete Punkt: Der nachtraegliche Weg reichte den
Eingangs-Eintrag nicht durch und rechnete deshalb IMMER mit dem EZB-Kurs, auch
wenn die Bank ihren eigenen genannt hatte. Jetzt wird er nachgeschlagen, und
der Kurs der Bank landet an der Buchung.

Und Loeschen raeumt die Eingangs-Eintraege mit. Der Fremdschluessel setzt sonst
nur ihre Verweisspalte auf NULL, und sie blieben als "gebucht" ohne Buchung
stehen – bei einem Uebertrag sind das zwei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:22:13 +02:00
GuidoandClaude Opus 5 8cb82da63c fix: Uebertrag buchen tat sichtbar nichts – useToast falsch ausgepackt
useToast() liefert die Melde-Funktion selbst, kein Objekt. In drei Dateien aus
dieser Sitzung stand "const { toast } = useToast()", also undefined. Der Aufruf
steht fast immer in einem catch – dort verschluckt der TypeError die
eigentliche Meldung, und nach aussen passiert gar nichts.

Das erklaert beide Beschwerden:

- "Als Uebertrag buchen" schien wirkungslos: Die Buchung lief durch, dann warf
  toast(), der catch warf beim Melden erneut, der Dialog blieb offen.
- "Braucht ein Reload": Im Eingang stand toast() VOR laden() – der Dialog
  schloss noch, das Neuladen kam nie. Die Cache-Kopfzeile, die ich dafuer
  gesetzt habe, war nicht die Ursache; richtig ist sie trotzdem.

Am Ende in der laufenden Oberflaeche nachgeklickt: Dialog schliesst, Meldung
erscheint, die Zeile wird ohne Neuladen zum Uebertrag, die Gegenseite liegt auf
dem Zielkonto, beide Salden stimmen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:15:43 +02:00
GuidoandClaude Opus 5 64b32cf8b8 fix: Tabelle passt wieder, und Uebertraege gehen auch aus der Buchungsliste
Die neue Kategoriespalte hatte 230 px Mindestbreite hinzugefuegt. Damit war die
Tabelle in einem schmalen Fenster breiter als ihre Karte und scrollte waagerecht
- von aussen sah das aus, als waere sie abgeschnitten. Alle Spalten sind jetzt
enger bemessen; bei 900 px Fensterbreite passt sie ohne Scrollen.

Ausserdem liess sich eine Buchung nur aus der Arbeitsliste zum Uebertrag
machen, nicht aus der Buchungsliste. Dort steht der Knopf jetzt auch - und zwar
statt des Loesen-Knopfes, denn beides zugleich gibt es nie: Eine verknuepfte
Buchung IST schon ein Paar.

Der Dialog dafuer ist eine eigene Komponente geworden. Die Nachfrage bei einem
geschaetzten Betrag - 409 abfangen, begruenden lassen, mit 'trotzdem' erneut
senden - ist zu leicht falsch nachgebaut, und jetzt gibt es sie nur einmal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:01:47 +02:00
GuidoandClaude Opus 5 654c53e3f5 feat: Kategorie als eigene Spalte in der Buchungsliste
Die Auswahl sass unter dem Buchungstext. Damit wurden die Zeilen unterschiedlich
hoch, die Spalte franste aus, und vergleichen liess sich nichts – genau das,
wofuer eine Tabelle da ist.

Jetzt zwei Spalten: 'Buchung' mit dem Text und darunter dem Verwendungszweck,
'Kategorie' mit der Auswahl. Ein Uebertrag zeigt dort einen Strich – er ordnet
kein Geld ein, er verschiebt es.

Die Spaltenliste hat sich damit geaendert, deshalb eine neue Kennung fuer die
Tabelle: Die gespeicherte Reihenfolge kennt die neue Spalte nicht und haette
die Textspalte ans Ende geschoben. Einmalig sind damit Breiten und Filter
dieser Tabelle zurueckgesetzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:51:23 +02:00
GuidoandClaude Opus 5 b303c87801 feat: Eine Arbeitsliste, und Kategorien direkt in der Buchungsliste
'Eingang' und 'Offene Buchungen' zeigten fast dasselbe. Seit angebundene Konten
selbst buchen, ist eine Bank-Buchung sofort gebucht und ohne Kategorie – sie
stand damit in BEIDEN Listen. Die Trennung stammte aus der Zeit, als der
Eingang nur Vorschlaege enthielt.

Jetzt eine Seite 'Zu erledigen' mit allem, was noch eine Hand braucht: offene
Vorschlaege zum Buchen oder Uebertragen und Buchungen ohne Kategorie, egal ob
von Bank, Mail, Kurzbefehl oder Datei. Aelteste zuerst, mit der Herkunft in der
Zeile. Erledigtes verschwindet – die Eingangs-Eintraege bleiben in der
Datenbank, weil der spaetere Import gegen sie abgleicht. /offen leitet um.

Das Haekchen 'Schlagwort merken' aus der alten Seite bleibt: Es ist der Grund,
warum der Stapel mit der Zeit kleiner wird, und der Eingang hatte es nie. Der
Uebertrags-Knopf gilt jetzt auch fuer schon gebuchte Buchungen – sonst haette
in einer gemeinsamen Liste die eine Haelfte der Zeilen ihn gehabt und die
andere nicht.

In der Buchungsliste steht die Kategorie jetzt als Auswahl in der Zeile, dort
wo bisher nur ihr Name stand. Vorher musste man jede Buchung einzeln zum
Bearbeiten oeffnen. Die Zeile aendert sich an Ort und Stelle, die
Bildlaufposition bleibt.

Ausserdem tragen API-Antworten jetzt 'Cache-Control: no-store'. Ohne das darf
ein Browser selbst entscheiden, und der Zurueck-Knopf zeigt Salden von vorhin –
bei Geld ist eine veraltete Anzeige schlimmer als eine langsame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:25:20 +02:00
GuidoandClaude Opus 5 8309f58a67 feat: Kurs der Bank statt EZB-Naeherung, und Saldo uebernehmen
Bei einer Fremdwaehrungs-Zahlung nennt die Bank oft ihren echten Kurs und den
Betrag in der Ausgangswaehrung. Certain warf das weg und naeherte mit dem
EZB-Kurs. Das faellt genau dort auf, wo es teuer ist: Die spaeter gelieferte
Gegenseite eines Uebertrags laesst sich mit einem geschaetzten Betrag nicht als
dieselbe Bewegung wiedererkennen.

Der Uebertrag waehlt jetzt in drei Stufen: der von der Bank genannte
Gegenbetrag (exakt), sonst ihr Kurs, sonst die EZB. Nur die erste Stufe gilt
als bekannt – daran haengt, ob bei einem angebundenen Zielkonto nachgefragt
wird.

Der Parser klopft mehrere Formen ab, weil die Feldnamen je Anbindung
verschieden sind, und setzt still nichts, wenn keine passt. Keine dieser Formen
ist gegen echte Daten geprueft; ein falscher Kurs saehe aus wie ein richtiger,
deshalb wird lieber gar keiner gesetzt.

Ausserdem ein Knopf auf der Kontenseite: den Saldo der Bank als richtig
uebernehmen. Die Differenz wandert in den ANFANGSBESTAND – sie steht fuer
Bewegungen, die Certain nie gesehen hat, und genau dafuer ist er da. Eine
Korrekturbuchung stuende dagegen fuer immer in der Liste und liefe durch jede
Auswertung. Die Buchungen bleiben unangetastet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:18:33 +02:00
GuidoandClaude Opus 5 8ceee5c5b1 feat: Ein Uebertrag faengt seine Gegenseite ab
Ein Uebertrag legte auf dem Zielkonto nichts an. Der Duplikatschutz konnte die
Gegenseite deshalb nicht sehen – er vergleicht import_hash, den eine
Uebertrags-Buchung nicht hat, und Eingangs-Eintraege, die es dort nicht gab.
Die schon vorliegende Gegenseite liess sich ein zweites Mal buchen, die
spaeter von der Bank gelieferte kam ungeprueft an: das Geld zweimal auf dem
Zielkonto.

Jetzt entsteht auf dem Zielkonto ein Eingangs-Eintrag, der auf DIESELBE Buchung
zeigt. Liegt dort schon ein passender offener Eintrag, wird dieser verbraucht
statt einen zweiten anzulegen – ueber denselben Abgleich wie beim Import, nicht
ueber eine zweite Vergleichslogik. Damit greift auch der vorhandene Schutz
gegen die spaeter eintreffende Gegenseite, ohne dass etwas Neues dafuer gebaut
werden musste.

Die Uebertrags-Logik liegt dafuer in einem eigenen Dienst. Zwei Wege fuehren
hinein – ein offener Vorschlag und eine schon gebuchte Buchung – und beide
brauchen dieselben vier Regeln. Der PATCH-Weg des Buchungs-Routers taete es
nicht: Er dreht die Richtung bei einer Einnahme nicht um und legt keine
Gegenseite an.

Die Sperre richtet sich nicht mehr nach der Waehrung, sondern danach, ob der
Betrag der Gegenseite BEKANNT oder geschaetzt ist. Bei gleicher Waehrung ist er
exakt, die Gegenseite wird sicher wiedererkannt – dann geht ein Uebertrag auf
ein angebundenes Konto ohne Nachfrage durch. Ueber Waehrungen hinweg bleibt die
Warnung, weil der EZB-Kurs nicht der Kurs der Bank ist.

Und Verwerfen raeumt beide Seiten: Bei einem Uebertrag zeigen zwei Eintraege
auf dieselbe Buchung, und der Fremdschluessel haette die andere als 'gebucht'
ohne Buchung zurueckgelassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:13:52 +02:00
GuidoandClaude Opus 5 90bda6b161 fix: Ein Uebertrag im Eingang fragt nicht mehr nach der Kategorie
Die Zeile im Eingang zeigte die Kategorie-Auswahl bedingungslos – auch bei
einem Kontouebertrag, der gar keine bekommt. Die Oberflaeche konnte es auch
nicht wissen: InboxItemOut hatte kein Feld, an dem sich ein Uebertrag
erkennen laesst.

Jetzt liefert der Eintrag transfer_account_id mit – das jeweils ANDERE
Konto, aus seiner Sicht. Steht es, zeigt die Zeile '↔ Kontoname' statt der
Auswahl. Ermittelt wird es mit einer Sammelabfrage, nicht je Zeile: Die Liste
geht bis 200 Eintraege.

Dazu die Luecke dahinter: set_category schrieb die Kategorie auch auf die
Buchung und konnte sie so auf einem TxnType.transfer ablegen – genau das,
was das Buchungsformular beim Anlegen verhindert. Bei einem Uebertrag lehnt
der Endpunkt jetzt ab; bei allem anderen bleibt es, wie es war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:08:09 +02:00
GuidoandClaude Opus 5 8084ba511a feat: Umsaetze je Konto holen, Takt fuer den automatischen Abruf
Drei Sachen an der Bankanbindung.

'Umsaetze holen' lief ueber alle Konten einer Verbindung. Seit angebundene
Konten selbst buchen, ist ein Klick damit folgenreich: Er bucht auf zwei
Konten. Der Knopf sitzt jetzt in der Kontozeile; an der Verbindung bleibt
'Alle Konten holen'.

Dabei musste der Zeitstempel mitwandern. last_sync_at hing an der
Verbindung – ein Einzelabruf haette damit das Startdatum der uebrigen Konten
mit vorgeschoben, und deren Umsaetze aus dem uebersprungenen Zeitraum waeren
nie angekommen. Der Stempel gehoert an das Konto, das abgerufen wurde.

Neu ist ein Takt je Verbindung, nach demselben Muster wie beim Postfach: ein
Hintergrund-Thread, der jede Minute nachsieht, ob etwas faellig ist. Bewusst
ein eigener Thread – ein haengender Bankzugang darf den Mail-Abruf nicht
mitreissen. Die Vorgabe ist 0, also aus: Anders als beim Mail-Abruf entstehen
hier Buchungen, das laeuft nicht ungefragt los. Schlaegt ein Abruf fehl, wird
der Stempel trotzdem gesetzt, sonst liefe die Verbindung jede Minute in
denselben Fehler.

Und die Abschnitte heissen jetzt 'Bankkonten Anbindung' und 'Mail Import'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:04:29 +02:00
GuidoandClaude Opus 5 a471eba617 feat: Kontouebertraege lassen sich im Eingang buchen
Bisher blieb im Eingang nur, eine Umbuchung als Ausgabe oder Einnahme zu
kategorisieren. Sie zaehlte dann als Ausgabe, obwohl kein Geld weg ist – nur
verschoben.

Jetzt gibt es je Vorschlag den Weg 'Als Uebertrag buchen': Zielkonto waehlen,
fertig. Es entsteht EINE Buchung, die beide Salden bewegt und in keiner
Auswertung zaehlt. Die Richtung kommt aus dem Vorzeichen – ein Abgang geht von
hier weg, ein Zugang kommt vom Gegenkonto her; entsprechend fragt der Dialog
'wohin' oder 'woher'. Bei verschiedenen Waehrungen wird die Seite exakt
gesetzt, die wir wirklich kennen, und die andere umgerechnet.

Der teuerste Fehler an dieser Stelle waere ein Zielkonto, das selbst an einer
Bank haengt: Es meldet den Zugang von allein, eine Gegenbuchung hier haette das
Geld zweimal. Der Server lehnt das mit einer Begruendung ab; wer es besser
weiss, bestaetigt und kommt durch. Das Wissen bleibt an einer Stelle – die
Oberflaeche fragt nicht selbst nach, sie zeigt die Antwort des Servers.

Die Betragslogik ist dafuer aus dem Buchungs-Router in einen Dienst gewandert.
Zwei Kopien derselben Umrechnung waeren genau die Stelle, an der Salden
auseinanderlaufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:31:49 +02:00
GuidoandClaude Opus 5 b9abb912e3 feat: Angebundene Konten buchen selbst – der Saldo kann nicht abweichen
Bisher legte der Bankabruf Vorschlaege in den Eingang, die man von Hand buchen
musste. Ein angebundenes Konto ist aber die Quelle der Wahrheit: Was die Bank
gebucht hat, IST gebucht. Jetzt wird es sofort gebucht; uebrig bleibt das
Zuordnen der Kategorie.

Drei Dinge mussten dafuer stimmen, sonst haette es Salden verfaelscht:

- Der erste Abruf greift nicht mehr hinter die neueste vorhandene Buchung des
  Kontos zurueck. Vorher holte er neunzig Tage – mitten in Monate, die schon
  per CSV eingelesen sind. Der Duplikatschutz haette die nicht gefunden: Sein
  Schluessel enthaelt den Empfaengertext, und der lautet bei der Bank anders
  als im Kontoauszug. Das Startdatum steht in der Rueckmeldung.
- Vorgemerkte Umsaetze werden nicht gebucht. Sie kommen spaeter noch einmal,
  dann mit anderer Kennung, weil die Bank beim Buchen eine neue vergibt.
  Beides zu nehmen hiesse doppelt buchen. Ihre Zahl wird gemeldet, nicht
  verschwiegen.
- Nach dem Buchen wird nach Uebertraegen gesucht. Ein Uebertrag zwischen zwei
  angebundenen Konten kommt in EINEM Abruf an – beide Seiten.

Nebenbei schliesst das eine bestehende Luecke: Der Eingangs-Abgleich beim
CSV-Import sieht nur gebuchte Eintraege. Solange Bank-Vorschlaege unbeachtet
liegen blieben, haette ein spaeterer Import derselben Wochen sie verdoppelt.

Das Buchen aus dem Eingang ist dafuer vom Router in einen Dienst gewandert;
zwei Kopien derselben Umrechnung waeren genau die Stelle, an der Salden
auseinanderlaufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:21:10 +02:00
GuidoandClaude Opus 5 7c53ee5dae feat: Einstellungen mit Abschnittsleiste statt Kachelfluss
Die Karten lagen in einem Spaltenfluss nebeneinander. Wo etwas landete, hing
von der Kartenhoehe ab und nicht vom Thema – man fand nichts wieder, und mit
jeder neuen Karte wurde es schlimmer.

Jetzt eine Spalte in fester Reihenfolge, daneben eine klebende Leiste mit den
Abschnitten: Sie zeigt, wo man gerade ist, und springt auf Klick dorthin. Die
Leiste wird aus derselben Liste gebaut wie die Abschnitte, damit kein Punkt
ins Leere zeigen kann. /settings#bankanbindung fuehrt direkt zum Abschnitt;
die Rueckkehr von der Bank nutzt das.

Drei Dinge, die beim Nachmessen auffielen und sonst kaputt gewesen waeren:

- Ein Klick setzt die Marke selbst. Ein kurzer Abschnitt am Seitenende bringt
  die Seite ans Ende, und die Seitenende-Regel haette dann einen anderen Punkt
  markiert als den angeklickten. Eigenes Scrollen hebt die Vorauswahl auf.
- Bei "Bewegung reduzieren" wird nicht sanft gescrollt: Der Browser fuehrt
  einen sanften Bildlauf dann gar nicht aus, der Sprung ginge ins Leere.
- Unter 760 px klebt die Abschnittsleiste nicht, weil dort die Hauptnavigation
  selbst die obere Leiste ist und sie verdeckt haette. Und die Spalten-
  Anordnung braucht align-items: stretch, sonst presst das flex-start von oben
  den Inhalt auf seine Inhaltsbreite statt auf die volle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:13:12 +02:00
GuidoandClaude Opus 5 2379cf4973 feat: Uebertraege lassen sich endlich bedienen
Der Uebertrags-Dienst und seine fuenf Endpunkte gab es seit Wochen – ohne
einen einzigen Aufrufer in der Oberflaeche. Man konnte also nirgends sagen,
dass zwei Buchungen derselbe Uebertrag sind, und eine gefundene Verknuepfung
auch nicht wieder loesen.

Jetzt: eine Karte "Moegliche Uebertraege" im Eingang mit beiden Seiten
nebeneinander, Verbinden und Ignorieren; in der Buchungsliste eine Marke mit
dem Gegenkonto und ein Knopf zum Loesen. Das Gegenkonto kommt vom Server mit –
die andere Buchung steht oft gar nicht in der gefilterten Liste, und
"Uebertrag" ohne Angabe wohin sagt nichts.

Ausserdem wird nach dem Buchen aus dem Eingang nach Uebertraegen gesucht, so
wie bisher nur nach einem Import. Seit die Bankanbindung ihre Umsaetze in den
Eingang legt, entsteht die Gegenseite naemlich erst dort; ohne diesen Aufruf
wuerde sie nie gefunden. Ein Fehler dabei kippt das Buchen nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:13:40 +02:00
GuidoandClaude Opus 5 63de8368f0 feat: Kontostand von der Bank – die eigene Rechnung wird geprueft
Certain rechnet den Saldo bisher ausschliesslich selbst aus: Anfangsbestand
plus alle Buchungen. Stimmt eine Buchung nicht, faellt das erst auf, wenn man
von Hand mit dem Auszug vergleicht.

Der von der Bank gemeldete Saldo wird jetzt mitgefuehrt und danebengestellt.
Weicht er ab, fehlt oder doppelt eine Buchung – und man sieht es auf der
Kontenseite und in den Einstellungen sofort, statt es zu suchen.

Banken melden mehrere Saldi nebeneinander; ausgewaehlt wird der gebuchte, denn
nur der ist mit Certains Rechnung vergleichbar. Ein verfuegbarer Saldo enthaelt
Vorgemerktes oder einen Ueberziehungsrahmen.

Die Saldi kommen auch fuer noch nicht zugeordnete Konten: Bei mehreren
Revolut-Pockets ist der Betrag oft das Einzige, woran man sie unterscheidet.
Ein Fehler bei einem Konto beendet den Durchlauf nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:08:56 +02:00
GuidoandClaude Opus 5 f684c752d8 feat: Pockets sind erkennbar und zuordenbar
Ein Revolut-Pocket bringt weder eigene IBAN noch eigenen Namen mit. Certain
hielt bisher nur uid, IBAN, Name und Waehrung fest – mehrere Konten derselben
Bank sahen damit gleich aus, und in der Zeile stand die rohe Kennung.

Jetzt werden Produktbezeichnung und die Kennung fuer Konten ohne IBAN
mitgefuehrt, der angezeigte Name faellt der Reihe nach auf Name, Details,
Produkt, Kennung, IBAN zurueck statt auf die uid, und der uebermittelte
Datensatz laesst sich aufklappen – nachsehen statt raten, wenn zwei Konten
gleich aussehen. Beim Neuverbinden werden die Merkmale aufgefrischt, ohne die
Zuordnung anzutasten.

Fehlt ein Konto in der Waehrung des Bankkontos, steht das jetzt dabei; vorher
war die Auswahl einfach leer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:02:25 +02:00
GuidoandClaude Opus 5 d8995116cb fix: Eingangsliste – Name bekommt Platz, Auswahl nicht mehr die ganze Zeile
Die Kategorie-Auswahl traegt "width: 100%". In einer Flex-Zeile heisst das
"ich haette gern die ganze Zeile", also blieb fuer den Namen daneben nur der
Rest – Buchungstexte brachen mitten im Wort um, waehrend das Auswahlfeld
uebertrieben breit dastand.

Jetzt hat die Auswahl in einer Listenzeile eine eigene Breite, die nur noch
schrumpfen darf, der Name nimmt den Rest, und die Betraege stehen in einer
Spalte statt ausgefranst. Auf dem Telefon rutschen Auswahl und Knoepfe unter
den Namen statt ihn zusammenzuquetschen; Datum und Kontoname bleiben dabei je
fuer sich unzertrennlich, damit "Revolut CHF" nicht umbricht – die Waehrung
im Namen unterscheidet die beiden Revolut-Konten.

Wirkt genauso auf die Seite "Ohne Kategorie", die dieselbe Zeile benutzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:53:48 +02:00
GuidoandClaude Opus 5 b4f76c03d2 feat: Erneut verbinden statt loeschen und neu anlegen
Um eine Verbindung aufzufrischen – etwa nachdem man im Control Panel weitere
Konten verknuepft hat – haette man sie bisher erst loeschen muessen. Dabei
gehen die Kontozuordnungen mit verloren, also genau das, was man behalten
will; ein zweiter Verbindungsversuch haette sonst einen doppelten Eintrag
erzeugt.

Jetzt frischt das Verbinden derselben Bank den bestehenden Eintrag auf: neue
Sitzung, neue Konten, bestehende Zuordnungen bleiben. Der Warntext beim
Trennen sagt ausserdem, dass Trennen dafuer gar nicht noetig ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:35:52 +02:00
GuidoandClaude Opus 5 277fe8b0e2 fix: "Keine Konten" nennt jetzt die Ursache
Drei verschiedene Faelle sahen alle gleich aus: Die Bank schickte nichts, sie
schickte Eintraege ohne Kennung (die still verworfen wurden), oder alle Konten
waren schon bekannt – Letzteres meldete faelschlich "0 Konten", obwohl alles
in Ordnung war.

Die Antwort unterscheidet jetzt uebermittelt / neu / bekannt / ohne_kennung.
Die Rueckkehr-Seite zeigt bei null nutzbaren Konten den passenden Hinweis: Im
eingeschraenkten Betrieb gibt eine Bank nur heraus, was zuvor im Control Panel
unter "Activate by linking accounts" verknuepft wurde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:32:23 +02:00
GuidoandClaude Opus 5 8fa4ac5ef4 fix: Hinweis nannte das falsche Erkennungsmerkmal fuer die Sandbox
Der Text riet, auf "Test- oder Mock-Institute" zu achten. Das ist falsch und
hat die Fehlersuche in die Irre gefuehrt: Die Sandbox-Liste enthaelt echte
Banknamen wie "Aachener Bank" und "Nordea" – nur N26 und Revolut fehlen. Wer
echte Namen sieht, schliesst die Sandbox faelschlich aus.

Jetzt verweist der Hinweis auf die Zeile "Environment" im Control Panel, wo
SANDBOX oder PRODUCTION eindeutig dabeisteht. Die Einrichtungsanleitung sagt
ausserdem gleich beim Anlegen, dass PRODUCTION gewaehlt werden muss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:19:32 +02:00
GuidoandClaude Opus 5 beb1bb3419 fix: Bank-Suche verlor die besten Treffer beim Kuerzen
Der Fehler, den der Nutzer gefunden hat: Die Suche nach "n" lieferte
"genau 200" Treffer und darunter nie N26. Ursache war die Reihenfolge –
alphabetisch sortiert und ERST DANN bei 200 gekuerzt. "N26" liegt hinter
"Aachener Bank", fiel also hinter zweihundert Banken heraus, die irgendwo
ein n im Namen tragen. Die Bank war die ganze Zeit da.

Jetzt wird zuerst bewertet (genauer Name > Namensanfang > Wortanfang >
Teiltreffer > nur BIC/Gruppe) und erst danach gekuerzt. Ausserdem wird die
ECHTE Trefferzahl gemeldet statt der gekuerzten – "genau 200" sah aus wie ein
Suchergebnis, war aber die Obergrenze.

Ein Regressionstest stellt den Fall nach: 300 Banken mit "n" im Namen,
alphabetisch vor N26. Ohne den Fix faellt er um.

Nebenbei geprueft und ausgeschlossen: /aspsps blaettert nicht, die Liste ist
also vollstaendig; Sandbox und alphabetische Kuerzung waren es auch nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:10:41 +02:00
GuidoandClaude Opus 5 f53efc4767 diag: Zeigen, was unter dem gesuchten Buchstaben steht
Die Liste ist nicht abgeschnitten – sie reicht von "Aachener Bank" bis
"Oelands Bank" und enthaelt N und R. Damit sind beide bisherigen Vermutungen
widerlegt (Sandbox, alphabetisch gekuerzt), und Raten ueber Namensvarianten
bringt nichts mehr.

Bleibt die Suche ohne Treffer, stehen jetzt alle Eintraege mit demselben
Anfangsbuchstaben da. Damit ist auf einen Blick zu sehen, ob die Bank unter
einem anderen Namen gefuehrt wird – oder ob sie in diesen 815 von ueber 2500
schlicht fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:57:53 +02:00
GuidoandClaude Opus 5 74641aeeac diag: Alphabetischen Bereich der Bankliste zeigen
815 geladene Banken bei ueber 2500 verfuegbaren, und alle Beispiele beginnen
mit A oder B – das deutet auf eine abgeschnittene Liste, nicht auf einen
falschen Suchbegriff. Die Sandbox-Vermutung ist damit widerlegt: "Aachener
Bank" und "BBVA" sind echte Institute.

Die Antwort nennt jetzt den Bereich von/bis und alle vorhandenen
Anfangsbuchstaben. Fehlen N und R, ist die Sache entschieden und die Abfrage
braucht Blaettern – dann suche ich nicht laenger am falschen Ende.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:54:58 +02:00
GuidoandClaude Opus 5 d3e4b0e089 fix: Bei keinem Treffer zeigen, was ueberhaupt geladen wurde
N26 und Revolut sind bei Enable Banking abgedeckt – in 31 Laendern, unter
genau diesen Namen. Wer sie trotzdem nicht findet, bekommt die falsche Liste,
und der haeufigste Grund ist eine als Sandbox statt Production angelegte
Anwendung: Dann kommen Test-Institute, man sieht also Banken, nur nicht die
echten.

Bleibt die Suche ohne Treffer, stehen jetzt acht Beispiele aus der geladenen
Liste da. Sind das lauter Mock-Institute, ist die Ursache auf einen Blick zu
sehen statt nach Namensvarianten zu raten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:52:04 +02:00
GuidoandClaude Opus 5 9a0c607e8c fix: "Keine Treffer" sagt jetzt, woran es liegt
Bei der Bank-Suche kam nur "Keine Treffer" – das hiess zweierlei: Die
Schnittstelle lieferte gar keine Liste, oder sie lieferte eine und der
Suchbegriff passte nicht. Beides sieht gleich aus, fuehrt aber an ganz
verschiedene Stellen. Genau diese Verwechslung hat heute schon zweimal Zeit
gekostet.

Die Antwort traegt jetzt die Gesamtzahl mit. Kam nichts, steht der
wahrscheinliche Grund dabei: Die Anwendung bei Enable Banking wird erst
aktiv, wenn man dort die eigenen Konten verknuepft hat.

Ausserdem wird breiter gesucht – auch in BIC und Gruppenname, weil Banken in
so einer Liste nicht immer so heissen wie im Alltag – und die Antwort wird
defensiv gelesen, falls die Liste ohne Schluessel kommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:48:59 +02:00
GuidoandClaude Opus 5 71e8419634 feat: Bank ueber alle Laender suchen
Unter "Deutschland" liessen sich N26 und Revolut nicht finden – und bei
Revolut kann das gar nicht klappen: Die Bank vergibt deutsche IBANs, ist aber
die Revolut Bank UAB in Litauen. Das Sitzland einer Bank steht nicht in der
IBAN, also ist die Laenderauswahl der falsche Einstieg.

Jetzt ein Suchfeld, das ohne Land ueber alle sucht; das Land ist nur noch ein
optionaler Filter. Der Treffer zeigt sein Land mit an, und verbunden wird mit
dem Land der BANK statt dem des Filters – sonst schluege die Autorisierung
fehl, sobald ohne Land gesucht wurde.

Ohne Land UND ohne Begriff wird abgelehnt: Sonst kaemen alle Banken Europas
ins Formular.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:42:08 +02:00
GuidoandClaude Opus 5 cba7ec31e3 fix: Schluesselfeld sagt, dass BEGIN/END dazugehoeren
"Inhalt der .pem-Datei" liess offen, ob die Rahmenzeilen mitgehoeren. Sie
gehoeren dazu – ohne sie ist es kein gueltiges PEM und die Anbindung
scheitert schon vor der ersten Anfrage. Steht jetzt im Hinweis und im
Platzhalter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:38:39 +02:00
GuidoandClaude Opus 5 735ece6843 feat: Oberflaeche fuer die Bankanbindung
Einstellungen -> "Bankkonten direkt verbinden": Zugangsdaten hinterlegen,
Land waehlen, Bank suchen, verbinden. Nach der Autorisierung landet man auf
/bank/rueckkehr, wo der Code gegen die Sitzung getauscht wird – ueber den
Server, denn der Code ist ein Einmal-Geheimnis und die Anfrage muss mit dem
privaten Schluessel signiert werden, der den Server nie verlaesst.

Danach stehen die gefundenen Bankkonten da und werden je einem Konto in
Certain zugeordnet. Die Auswahl zeigt nur Konten passender Waehrung, und
solange nichts zugeordnet ist, steht dabei, dass von diesem Konto nichts
abgerufen wird.

Die Einrichtungsanleitung nennt die Rueckleitungs-Adresse kopierfertig – sie
muss bei Enable Banking hinterlegt werden, und ohne sie scheitert die
Autorisierung mit einer Fehlermeldung der Bank, die nichts erklaert.

Laeuft die Zustimmung bald ab, steht das farbig an der Verbindung statt dass
der Abruf eines Tages stumm aufhoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:34:41 +02:00
GuidoandClaude Opus 5 7a3bd200b1 feat: Bankanbindung ueber PSD2 (Backend)
Seit PSD2 muessen EU-Banken eine Schnittstelle anbieten, aber nur fuer
lizenzierte Dienste mit eIDAS-Zertifikat. Enable Banking haelt die Lizenz und
reicht sie weiter: eigene Anwendung registrieren, eigene Konten verknuepfen,
danach genau diese lesen. Deckt N26 und Revolut ab; die Schweiz faellt nicht
unter PSD2, Raiffeisen bleibt beim CSV-Weg.

Es gibt keinen API-Schluessel. Jede Anfrage traegt ein selbst gebautes JWT,
signiert mit dem privaten Schluessel der Anwendung (RS256). Signiert wird mit
cryptography, das ohnehin schon fuer die Verschluesselung im Projekt ist – der
Test prueft die Signatur echt gegen den oeffentlichen Schluessel, denn genau
daran scheitert so eine Anbindung sonst als Erstes.

Abgerufene Umsaetze werden NICHT gebucht. Sie laufen durch detect_rows und
landen als Vorschlag im Eingang – dieselbe Strecke wie Mail und Kurzbefehl,
also greifen Duplikatschutz und Schlagwortregeln ohne Sonderbehandlung. Eine
dritte Quelle, kein dritter Sonderweg.

Bewusst eng gehalten: nur Lesen; Zuordnung Bankkonto -> Konto trifft der
Nutzer; die Zuordnung verlangt dieselbe Waehrung; die Rueckleitungs-Adresse
wird eingetragen statt geraten (hinter einem Proxy sieht der Dienst "http",
obwohl der Browser HTTPS spricht) und muss HTTPS sein.

Der Ablauf der Zustimmung (bis 180 Tage) wird sichtbar gemeldet, damit der
Abruf nicht eines Tages stumm aufhoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:25:20 +02:00
GuidoandClaude Opus 5 3ee18c4d21 fix: Neue Tabellen ins Backup, toter Transfer-Code raus
Sieben nutzereigene Tabellen aus den Migrationen 0015-0020 standen in keiner
Backup-Liste. Am schwersten wiegt inbox_items: Der Eingang haelt fest, welche
Zahlungen von aussen schon erfasst wurden. Fehlt er nach dem Wiedereinspielen,
erkennt der naechste Kontoauszug sie nicht wieder und bucht sie ein zweites
Mal – genau der Fehler, den die Tabelle verhindern soll.

Aufgenommen: inbox_items, transfer_links, link_ignored, api_tokens.
Bewusst nicht: mail_sources/mail_senders (tragen das verschluesselte
IMAP-Passwort; ein Backup ist eine Datei, die man herumreicht) und
pending_uploads (fluechtige Rohdateien). Beides steht begruendet im Code.

Ein Test laeuft jetzt ueber Base.registry und fordert fuer jedes Modell mit
owner_id eine bewusste Entscheidung – sichern oder begruendet ausnehmen.
Genau diese Pruefung fehlte, weshalb die sieben unbemerkt durchrutschten.

Dazu der tote Code aus dem Transfer-Ausbau: is_counterpart und braucht_ziel
waren feste False, _apply_counterpart_amount damit unerreichbar. Die Tests
sichern dieselbe Garantie jetzt ueber das tatsaechliche Verhalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:16:33 +02:00
GuidoandClaude Opus 5 c9a598a056 feat: Ueberträge als Verknuepfung erkennen, ohne Buchungen anzufassen
Ein Uebertrag steht in zwei Auszuegen und wird zweimal gebucht – richtig fuer
die Salden, aber er zaehlt als Ausgabe UND als Einnahme in der Auswertung.

Statt beide zu einer Uebertrags-Buchung zusammenzufuehren (der urspruengliche
Plan) legt Certain nur eine Schicht darueber: transfer_links haelt fest, dass
zwei Buchungen zusammengehoeren. An den Buchungen selbst aendert sich nichts –
Betrag, Konto, Kategorie, Import-Schluessel und Stapel bleiben.

Das haelt alles heraus, was am Zusammenfuehren haette brechen koennen: der
Duplikatschutz verliert keinen Schluessel, undo_batch reisst keine Gegenseite
mit, und eine falsche Paarung ist ein Etikett, das man abzieht.

Die Auswertung ueberspringt verknuepfte Buchungen – nicht umkategorisiert:
Loest man die Verknuepfung, zaehlt die Buchung mit ihrer alten Kategorie
sofort wieder mit.

Automatisch nur, wenn die Zuordnung zwingend ist UND keine Seite eine
Kategorie traegt UND der Text auf einen Uebertrag hindeutet. Sonst Vorschlag.
Waehrungswechsel werden ueber den EZB-Kurs des Tages geprueft (2,5 % Toleranz
fuer den Bankaufschlag) – der Kurs dient nur als Pruefung, gespeichert wird
nichts davon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:41:34 +02:00
GuidoandClaude Opus 5 e8a862bd63 feat: Konto wird aus der Datei erkannt – ein Kurzbefehl genuegt
Bisher brauchte es je Konto einen eigenen Kurzbefehl, weil account_id
Pflicht war. Jetzt ist sie optional: Raiffeisen fuehrt die eigene IBAN in der
Datei mit (Treffer gegen die Erkennungs-Texte der Konten), Revolut die
Waehrung – zusammen mit der erkannten Bank und dem Import-Verlauf reicht das.

Bleibt es mehrdeutig, wird NICHT geraten: Ein Auszug im falschen Konto
verfaelscht zwei Salden auf einmal. Stattdessen nennt die Fehlermeldung die
Konten samt Nummern, sodass man sie im Kurzbefehl nachtragen kann. Hat der
Schluessel ein Standardkonto, gilt das.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:54:27 +02:00
GuidoandClaude Opus 5 1b401ff358 fix: "Marke" heisst jetzt Geraeteschluessel
"Geraetemarke" war ein ausgedachtes Wort, das nirgends erklaert wurde – man
las "Marke" und wusste nicht, was gemeint ist oder woher man sie bekommt.
Jetzt "Geraeteschluessel", mit einem Einleitungstext, der sagt was es ist
(ein langes Passwort nur fuers Telefon, nicht das Anmelde-Passwort), was es
darf und was beim Verlust zu tun ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:49:53 +02:00
GuidoandClaude Opus 5 4a17b520b6 fix: Kurzbefehl-Anleitung dauerhaft sichtbar
Die Anleitung – und damit die Konto-Nummern fuer account_id – erschien nur
direkt nach dem Anlegen einer Marke. Wer sie ausblendete oder die Seite neu
lud, kam nie wieder daran und stand vor dem Kurzbefehl ohne die Nummer, die
er eintragen sollte.

Einmalig ist nur der Klartext der Marke; die Anleitung steht jetzt immer da,
mit Platzhalter statt Klartext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:49:00 +02:00
GuidoandClaude Opus 5 5ff9b63d0a fix: Fehler am Kurzbefehl-Endpunkt sind auf dem Telefon lesbar
Der Kurzbefehl zeigt das Feld "message" an. Fehlerantworten liefern aber
"detail" – die Mitteilung auf dem Telefon blieb also leer. Man sah nicht
einmal, DASS etwas schiefging: Die Marke wurde als benutzt vermerkt, auf dem
Server kam trotzdem nichts an, und die Ursache war von aussen unsichtbar.

Antworten unter /api/quick liefern jetzt zusaetzlich "message" mit einem
lesbaren Text. Bei fehlenden Pflichtfeldern nennt er sie beim Namen und
erinnert daran, dass der Anfragetext beim Datei-Kurzbefehl 'Formular' sein
muss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:47:10 +02:00
GuidoandClaude Opus 5 84605cbc0f fix: Wartende Telefon-Dateien stehen jetzt oben
Die Karte "Vom Telefon empfangen" sass unter dem gesamten Import-Assistenten
kurz vor dem Verlauf – wer eine Datei geschickt hatte, fand sie nicht und
sah eine leere Seite. Eine wartende Datei ist das Erste, was man tun will,
also steht sie jetzt ueber Schritt 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:37:33 +02:00
GuidoandClaude Opus 5 f3d80eb6bc feat: Konto-Nummern in der Kurzbefehl-Anleitung
Fuer den Datei-Kurzbefehl braucht man die account_id, und die stand nirgends
in der Oberflaeche – man haette sie ueber die API nachschlagen muessen. Jetzt
listet die Anleitung Name und Nummer je Konto, markierbar zum Kopieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:18:16 +02:00
GuidoandClaude Opus 5 3b034dc001 feat: Format wird beim Dateiwaehlen automatisch erkannt
Waehlt man eine Datei (oder uebernimmt eine vom Telefon), erkennt Certain
Trennzeichen, Dezimalzeichen, Datumsformat und Spaltenzuordnung selbst und
traegt sie ein. Darueber steht, WAS erkannt wurde – "Format erkannt:
Revolut" oder die Spaltenzuordnung im Klartext –, damit man es pruefen kann,
statt ihm blind zu vertrauen.

War etwas nicht eindeutig, steht das ausdruecklich da mit dem Hinweis, die
Zuordnung unten anzusehen. Das Formular bleibt also erreichbar, muss aber im
Normalfall nicht mehr angefasst werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:19:46 +02:00
GuidoandClaude Opus 5 e6801b0474 feat: CSV-Format automatisch erkennen
Bisher musste man bei jedem Import Trennzeichen, Datumsformat und
Spaltenzuordnung von Hand eintragen – die haeufigste Fehlerquelle ueberhaupt:
Ein falsches Datumsformat liess alle Zeilen durchfallen, ein falscher
Dezimaltrenner verzehnfachte Betraege.

Zuerst wird gegen die Bank-Vorlagen geprueft (Kopfzeilen-Signatur); alle vier
echten Ausaege des Nutzers werden so sicher erkannt. Sonst inhaltsbasiert:
welche Spalte enthaelt Daten, welche Betraege, welche den laengsten Text.

Zwei Fallen, die dabei aufgefallen sind und getestet werden:
- Deutsche Datumsangaben stecken voller Punkte, und viele Auszuege fuehren
  Buchungs- UND Valutadatum. Ohne sie herauszurechnen kaeme "." als
  Dezimalzeichen heraus und aus 1.204,70 wuerden 1.20.
- parse_date ist absichtlich nachsichtig; beim Erkennen darf es das nicht
  sein, sonst gewinnt ein Format mit Uhrzeit gegen das richtige ohne.

Ganzzahlen ohne Vorzeichen zaehlen nur halb, damit Referenznummern und Kurse
nicht als Betragsspalte durchgehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:17:37 +02:00
GuidoandClaude Opus 5 c47525ff7e feat: Vorschlaege buchen und Telefon-Dateien nur ablegen
Zwei Luecken geschlossen:

Mail-Vorschlaege standen als "pending" im Eingang, aber es gab keinen Knopf
zum Buchen – der Weg war hinten offen. Jetzt je Eintrag "Buchen" und ein
"Alle buchen". Unklare bleiben liegen: Bei ihnen fehlt etwas oder die
Erkennung war unsicher, die soll man ansehen statt im Stapel durchzuwinken.

Der Auszug vom Telefon bucht nicht mehr sofort, sondern wird nur abgelegt.
Am Telefon prueft man keine 500 Zeilen; die Datei wartet in der
Import-Ansicht und laeuft dort durch die normale Vorschau mit
Saldo-Abgleich, Zeilenfilter und Duplikatpruefung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:12:58 +02:00
GuidoandClaude Opus 5 34be575eb3 feat: Kontoauszug vom Telefon per Teilen-Menue einlesen
Revolut und Raiffeisen lassen die CSV auch am Handy exportieren; nur N26
verlangt die Web-Version. Ein zweiter Kurzbefehl nimmt die Datei aus dem
Teilen-Menue entgegen und schickt sie mit dem gewaehlten Konto an
/quick/statement.

Am Telefon laesst sich keine Spaltenzuordnung vornehmen. Der Endpunkt
verwendet deshalb die Einstellung, mit der dieses Konto zuletzt erfolgreich
eingelesen wurde – und sagt klar Bescheid, wenn es noch keine gibt, statt
mit falschen Spalten zu raten.

Gebucht wird sofort: Ein Auszug hat hunderte Zeilen, die einzeln abzunicken
waere keine Erleichterung. Sicher ist das durch die Duplikaterkennung und
weil der Stapel im Import-Verlauf mit einem Klick zurueckzunehmen ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:06:20 +02:00
GuidoandClaude Opus 5 e93f565629 fix: Einstellungskarten in feste Spalten statt Spaltenfluss
Beim Abrufen und beim Bearbeiten sprang die halbe Seite. Ursache war der
CSS-Spaltenfluss: Aendert eine Karte ihre Hoehe, verteilt der Browser ALLE
Karten neu auf die Spalten. Ich hatte das zweimal am Symptom kuriert
(Formular in den Dialog verschoben), aber die naechste wachsende Karte
brachte es zurueck.

Jetzt bekommt jede Karte fest ihre Spalte. Waechst eine, verschiebt sich
nur, was in derselben Spalte darunter steht. Die Spaltenzahl kommt aus der
tatsaechlichen Containerbreite (ResizeObserver), nicht aus der
Fensterbreite – in der schmalen Seitenleisten-Ansicht stimmte das sonst
nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:54:03 +02:00
GuidoandClaude Opus 5 899241f991 feat: Absender-Zuordnung kann nach Waehrung unterscheiden
Revolut verschickt fuer eine Zahlung in Euro und eine in Franken dieselbe
Mail vom selben Absender. Ohne die Waehrung als Kriterium landete alles auf
demselben Konto.

Die Erkennung liest die Waehrung ohnehin aus; sie wird jetzt VOR der
Zuordnung ausgewertet und ist ein optionales Filterkriterium. Zuordnungen
mit Waehrung werden zuerst geprueft – wer neben ein allgemeines "revolut"
ein genaues "revolut + EUR" stellt, meint das genaue, unabhaengig von der
Anlagereihenfolge.

Uebergangene Mails melden ausserdem die erkannte Waehrung mit, damit beim
Einrichten sichtbar ist, welcher Wert zu filtern waere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:48:55 +02:00
GuidoandClaude Opus 5 512dac2b04 fix: Beschriftungen unabhaengig von Umlaut und Zitat erkennen
Ohne den Beguenstigten ist ein Eintrag im Eingang wertlos – man sieht einen
Betrag und weiss nicht, wofuer. Genau das passierte bei weitergeleiteten
Mails: Gmail zitiert den Text mit "> " davor, die Beschriftung stand also
nicht mehr am Zeilenanfang, und je nach Kodierung kam "Beguenstigter" statt
"Beguenstigter" mit Umlaut an.

Beschriftungen werden jetzt normalisiert verglichen (Umlaute verschriftlicht,
alles ausser Buchstaben und Ziffern verworfen), und Zitatzeichen fallen vor
der Feldsuche weg. Die Suchbegriffe laufen durch dieselbe Normalisierung,
damit sie lesbar bleiben statt doppelt gepflegt zu werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:36:49 +02:00
GuidoandClaude Opus 5 7b087247a5 feat: Absender-Zuordnungen bearbeiten und loeschen
Der Loesch-Endpunkt war da, aber ohne Knopf in der Oberflaeche, und
Bearbeiten fehlte ganz – eine falsch eingetippte Zuordnung liess sich also
nicht mehr korrigieren.

Jede Zuordnung ist jetzt eine eigene Zeile mit Erkennungstext und Konto;
der Speichern-Knopf erscheint erst bei einer Aenderung. Dazu ein PUT auf
/mail/senders/{id} im Backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:30:24 +02:00
GuidoandClaude Opus 5 615be0f0f5 fix: Erkennungstext greift auch in Betreff und Text
Bei einer von Hand weitergeleiteten Bank-Mail steht die eigene Adresse im
From; "Raiffeisen" findet sich nur noch im Betreff ("WG: Raiffeisen …")
und im zitierten Kopf. Das Feld hiess "Absender enthaelt" und prueft nur die
Absender-Koepfe – die Mail wurde also uebergangen, obwohl das Suchwort
sichtbar darin stand. Eine Falle, kein Bedienfehler.

Gesucht wird jetzt in Absender-Koepfen, Betreff und den ersten 2000 Zeichen
des Textes; die Beschriftung heisst entsprechend "Erkennungstext". Ein Test
mit einer nachgebauten Weiterleitung haelt fest, dass sowohl die Zuordnung
greift als auch Betrag und Empfaenger daraus lesbar bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:27:54 +02:00
GuidoandClaude Opus 5 bfb6fa6b6d fix: Abruf sucht nach Datum statt nach "ungelesen"
Auf UNSEEN zu filtern klingt sparsam, ist aber eine Falle: Wer eine Mail in
seinem Mailprogramm ansieht, bevor Certain sie geholt hat, verliert sie
dauerhaft – sie ist dann fuer immer gelesen. Genau das ist passiert, als die
Mails vor dem ersten Abruf schon in Gmail geoeffnet waren: Ergebnis "0 neu",
ohne jeden Hinweis auf die Ursache.

Gesucht wird jetzt ab dem letzten Abruf mit einem Tag Ueberlappung; dass
nichts doppelt ankommt, sichert die Message-ID ab.

Die Meldung nennt ausserdem, wie viele Mails ueberhaupt geprueft wurden.
"0 neu" allein liess offen, ob nichts da war oder nichts passte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:25:47 +02:00
GuidoandClaude Opus 5 f3a08f2937 feat: Weitergeleitete Bank-Mails erkennen und Uebergangenes zeigen
Wer Bank-Mails in ein Sammelpostfach weiterleitet, hat im From je nach
Methode die eigene Adresse stehen. Geprueft werden deshalb auch Reply-To,
Return-Path, Sender und X-Forwarded-For – bei automatischer Weiterleitung
bleibt der Originalabsender dort erhalten.

Ausserdem meldet der Abruf jetzt, welche Mails er uebergangen hat, mit
Absender und Betreff. Beim Einrichten war das sonst Raten: Man sah nur
"3 uebersprungen" und wusste nicht, was in den Filter gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:23:35 +02:00
GuidoandClaude Opus 5 31f550901a fix: Postfach bearbeiten im Dialog statt in der Karte
Drei zusammenhaengende Aergernisse:

Der Papierkorb wurde zum roten Balken ueber die volle Breite. Ursache ist
.btn-pair: dort bekommt jeder Knopf flex:1, und ein umgebrochener Knopf
dehnt sich damit ueber die ganze Zeile. Die Aktionen haben jetzt eine
eigene Zeile, die umbricht ohne zu dehnen.

Das Formular wuchs innerhalb der Karte und ordnete damit den Spaltenfluss
der ganzen Einstellungsseite neu – deshalb sprang beim Bearbeiten das
Layout. Es liegt jetzt in einem Dialog, wie schon bei den Konten. Das
loest zugleich die Enge: im Dialog hat das Formular Platz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:20:01 +02:00
GuidoandClaude Opus 5 51decb7054 feat: Verschluesselung waehlbar, dazu zwei Layout-Fehler
Verschluesselung: Bisher gab es nur den Schalter use_ssl – also implizites
TLS auf 993 oder gar nichts. STARTTLS auf 143 fehlte ganz, und das bieten
viele Anbieter an. Jetzt ein Feld mit drei Werten, der Port zieht bei der
Auswahl mit, und "keine" warnt, dass Zugangsdaten im Klartext gehen.

Layout: Die Postfach-Karte steht in einer schmalen Spalte; der waagerechte
Aufbau brach dort auf ein Wort je Zeile um und die Knoepfe lagen ueber dem
Titel. Jetzt gestapelt.

Die Einstellungsseite nutzte ein Raster, das die Karten zeilenweise
ausrichtet – die zweite Reihe begann erst unter der hoechsten Karte der
ersten, weshalb "Backup zurueckladen" allein weit unten stand. Jetzt
fliessen die Karten spaltenweise, und die Spalten sind breiter: vier
schmale Karten nebeneinander waren auf dem Notebook nicht lesbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:14:37 +02:00
GuidoandClaude Opus 5 408b88f893 fix: Kurzbefehl-Anleitung nennt Apples tatsaechliche Aktionsnamen
"Text erfragen" war der uebersetzte englische Name, nicht der, der in
Kurzbefehle steht. Die Aktion heisst "Eingabe anfordern". Da sich die
Bezeichnungen je nach iOS-Version aendern, nennt die Anleitung jetzt den
Suchbegriff und den englischen Namen dazu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:03:32 +02:00
GuidoandClaude Opus 5 8e41cce9a2 feat: Postfach-Formular in den Einstellungen
Server, Port, Benutzer und Passwort eintragen, Verbindung testen, Ordner
danach aus der echten Liste des Postfachs waehlen. Je Absender wird ein
Zielkonto zugeordnet – ohne Zuordnung uebernimmt Certain nichts, denn
dieselbe Adresse kann je nach Konto etwas anderes bedeuten.

Der Hinweis am Passwortfeld sagt, was passiert, wenn das Server-Geheimnis
getauscht wird, und empfiehlt ein App-Passwort statt des echten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:00:06 +02:00
GuidoandClaude Opus 5 fc6ee2f345 feat: Postfaecher einrichten und Zahlungs-Mails abholen (Etappe B, Teil 2)
- Verschluesselung fuer Zugangsdaten: Ein IMAP-Passwort laesst sich nicht
  hashen, Certain muss es dem Mailserver vorlegen. Es liegt deshalb mit
  Fernet verschluesselt in der Datenbank, der Schluessel wird per HKDF aus
  session_secret abgeleitet und nirgends gespeichert. Wird das Geheimnis
  getauscht, meldet Certain das verstaendlich statt mit einem Stacktrace.
- IMAP-Abruf ueber imaplib/email (Standardbibliothek). Ordnerliste,
  Verbindungstest mit klarer Fehlermeldung, Absender-Zuordnung auf Konten,
  danach als gelesen markieren oder verschieben.
- Wiedererkennung ueber die Message-ID statt der UID – die vergibt der
  Server beim Verschieben neu.
- Nichts wird stillschweigend gebucht: Eine Mail wird zum Eingangs-Eintrag.
  Fehlt das Zielkonto oder ist ein Feld unsicher, gilt sie als unklar.
- Hintergrund-Thread beim Start, nach dem Muster des EZB-Kursabrufs: ein
  langsames Postfach darf den Dienst nie blockieren.

Getestet gegen einen IMAP-Doppelgaenger, damit die Suite ohne Netz laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:58:06 +02:00
GuidoandClaude Opus 5 259013eafe fix: Marken-Formular in der schmalen Spalte lesbar
Die Karte steht in einer schmalen Spalte; die waagerechte Zeile quetschte
Beschriftung und Feld ineinander, sodass "Name des Geraets" ueber
"Standardkonto" lag. Jetzt gestapelt wie die uebrigen Formulare der Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:51:45 +02:00
GuidoandClaude Opus 5 7fb7df3554 feat: Zahlungsdaten aus Benachrichtigungs-Mails lesen (Etappe B, Teil 1)
Kein festes Schema je Bank, sondern beschriftete Felder suchen. Die beiden
Banken bauen ihre Mails verschieden: Raiffeisen schickt Klartext mit
"Betrag: 14.90 CHF", Revolut nur HTML mit dem Wert UNTER der Beschriftung.
Beide Bauformen werden abgedeckt, dazu der Betreff als eigene Quelle.

Jeder Wert merkt sich, woher er stammt – ohne das laesst sich ein falscher
Betrag spaeter nicht erklaeren.

Was bewusst NICHT geraten wird: Stehen mehrere gleichwertige Betraege ohne
Beschriftung da (Umsatz und neuer Saldo), bleibt das Feld unklar. Ein
Zahlungsauftrag mit Status "Zur Visierung" gilt nicht als geflossen. Blosse
Zahlen ohne Waehrung sind keine Betraege, und Stilbloecke werden vor dem
Entfernen der Tags herausgeschnitten – sonst haelt das CSS "480px" fuer
Geld.

Die Muster unter tests/fixtures/mail sind echte Mails, anonymisiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:46:31 +02:00
GuidoandClaude Opus 5 bf656dc957 feat: Oberflaeche fuer die Schnellerfassung
- "Mein Konto": Geraetemarken anlegen und widerrufen. Der Klartext ist
  nur einmal zu sehen, daneben steht eine Bauanleitung fuer den
  Kurzbefehl mit kopierfertiger Adresse und Kopfzeile. Ein fertiger
  Download geht nicht: seit iOS 15 muessen Kurzbefehl-Dateien signiert
  sein, und signieren kann nur ein Mac mit Apple-Konto.
- Neue Seite "Eingang": was unterwegs erfasst wurde, mit
  Kategorie-Auswahl und Verwerfen.
- Die Import-Pruefliste unterscheidet jetzt, WARUM eine Zeile
  uebersprungen wird: "schon erfasst" mit Erklaerung statt nur
  "Duplikat".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:39:31 +02:00
GuidoandClaude Opus 5 0d7f6a8d79 fix: Schlagwort-Test kollidierte mit anderen Tests
Die Testdatenbank lebt ueber den ganzen Lauf und Schlagwoerter sind je
Nutzer eindeutig; "coop" legt bereits ein anderer Test an. Der Test lief
allein durch und fiel im Verbund um. Jetzt mit einem zufaelligen Wort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:35:46 +02:00
GuidoandClaude Opus 5 02260d7cd2 feat: Schnellerfassung per Kurzbefehl (Etappe A)
Der Kontoauszug kommt erst am Monatsende; wer im Laden bezahlt hat, weiss
in dem Moment noch, wofuer. iOS laesst keine App fremde Mitteilungen
mitlesen, also fuehrt der Weg ueber einen Kurzbefehl, der die Zahlung an
Certain schickt.

Gebaut:
- Geraetemarken mit eigener Abhaengigkeit statt Sonderfall in
  get_current_user. Eine Marke gilt NUR an /quick – auf allen anderen
  Wegen ist sie keine Anmeldung. Geht das Telefon verloren, ist kein
  Datenabfluss moeglich; Tests halten das fuer Buchungen, Konten,
  Kategorien, Export und Datenbanksicherung fest.
- POST /quick/booking laeuft durch detect_rows, damit Schlagwort- und
  Betragsregeln die Kategorie schon vorschlagen. external_id verhindert,
  dass ein doppelt ausgeloester Kurzbefehl zweimal bucht.
- inbox_items merkt sich die Zahlung ueber das Buchen hinaus. Beim
  spaeteren CSV-Import greift der Import-Schluessel nicht: Der Text vom
  Telefon ist selten wortgleich mit dem des Auszugs und die Bank bucht ein
  bis zwei Tage spaeter. detect_rows vergleicht deshalb zusaetzlich
  unscharf – exakter Betrag, Datum +/- 3 Tage, je Eintrag nur einmal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:34:14 +02:00
GuidoandClaude Opus 5 9d6a53a224 test: Duplikaterkennung ueber Monatsgrenzen abgesichert
Der uebliche Ablauf ist, Mitte Monat einzulesen und Ende Monat den ganzen
Monat noch einmal. Die Tests halten fest, dass bereits gebuchte Zeilen
nicht erneut in den Saldo laufen, neue ankommen – und dass zwei bzw. drei
echte gleich aussehende Buchungen desselben Tages erhalten bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:56:43 +02:00
GuidoandClaude Opus 5 18368e6e93 feat: Buchungstexte in der Offen-Liste kopierbar
body hat user-select:none mit einer Ausnahmeliste; die Buchungsliste steht
als Tabelle darin, die Offen-Seite mit ihren Listenzeilen aber nicht. Damit
liess sich der Empfaengertext nicht markieren – ausgerechnet dort, wo man
ihn nachschlagen will, um zu erkennen, welches Franchise dahintersteckt.

.named-text ist jetzt markierbar, und je Zeile steht ein Kopierknopf.
Ausserdem wird der Verwendungszweck mit angezeigt, wenn er vom Empfaenger
abweicht – oft steht die eigentliche Kennung dort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:59:51 +02:00
GuidoandClaude Opus 5 3a44509e77 feat: Import-Vorlage richtet sich nach dem gewaehlten Konto
Beim Kontowechsel musste man die Vorlage jedes Mal neu waehlen – und las
im schlimmsten Fall den Revolut-Auszug mit der Raiffeisen-Vorlage ein
(genau das steht im Verlauf).

Eigene Vorlagen standen schon als template_id im Verlauf; Bank-Vorlagen
sind fest im Code und hinterliessen nichts. Der Verlauf merkt sich deshalb
jetzt auch deren Namen (Migration 0014). Die Oberflaeche waehlt beim
Kontowechsel, womit zuletzt in dieses Konto importiert wurde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:44:50 +02:00
GuidoandClaude Opus 5 78835d1e40 feat: Konto-Auswahl mit Symbol und Farbe
Das native <select> zeigte nur den Namen im Systemstil. Bei einem Dutzend
Konten erkennt man seines am Symbol deutlich schneller – und beim Import
sieht man sofort, ob man in das gemeinte Konto liest.

Neue Komponente AccountSelect nach dem Muster von CategorySelect, mit
denselben Klassen und per Portal gerendert. Eingesetzt beim Import, in
Filter und Formular der Buchungen sowie beim Zielkonto eines Uebertrags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:42:14 +02:00
GuidoandClaude Opus 5 6390f7c325 feat: Seite "Buchungen ohne Kategorie" zum Abarbeiten
Bisher musste man in der Gesamtliste nach Luecken suchen. Der Endpunkt
kennt jetzt ?uncategorized=true; Uebertraege bleiben aussen vor, denn sie
ordnen kein Geld ein, sie verschieben es nur.

Die Seite zeigt die offenen Buchungen aelteste zuerst; nach dem Zuordnen
verschwindet die Zeile, sodass der Stapel sichtbar kleiner wird. Auf Wunsch
wird der Zweck gleich als Schlagwort gemerkt, damit die naechste gleiche
Buchung schon beim Import zugeordnet wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:40:52 +02:00
GuidoandClaude Opus 5 9642901e4e feat: Kontenreihenfolge selbst bestimmen
Die Liste stand in der Anlegereihenfolge, die selten die gewuenschte ist.
Neuer Endpunkt PUT /accounts/order nimmt die IDs in der gewuenschten Folge
und schreibt die Position; er muss vor /{account_id} stehen, sonst faengt
die ID-Route den Pfad ab. Fremde IDs werden abgewiesen.

In der Kontenliste zwei Pfeile je Zeile, optimistisch sortiert, damit die
Liste beim Klicken nicht springt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:36:59 +02:00
GuidoandClaude Opus 5 ff897c196b fix: Dropdown bleibt beim Scrollen offen, Dialog beim Markieren
Zwei Klick-Aergernisse mit derselben Ursache – ein zu grob gefasster
Schliess-Ausloeser.

Die Kategorieliste schloss bei jedem Scroll-Ereignis, wegen capture=true
auch beim Scrollen IN der Liste. Genau das will man aber tun. Jetzt zaehlt
nur noch Scrollen ausserhalb.

Der Dialog schloss, sobald ein Klick auf dem Hintergrund endete. Markiert
man Text in einem Feld und zieht ueber den Rand hinaus, liegt das Mouseup
dort – der Dialog ging mitten im Markieren zu. Jetzt muss der Klick auch
auf dem Hintergrund begonnen haben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:31:18 +02:00
GuidoandClaude Opus 5 d8c15ca260 feat: Rueckgaengig gemachte Importe sammelweise entfernen
Die Aktionsspalte im Verlauf ist fest und war zu schmal fuer den
beschrifteten Knopf. Ausserdem sammeln sich nach einer Testreihe schnell
zwanzig rueckgaengig gemachte Eintraege – die einzeln wegzuklicken ist
Fleissarbeit. Neben der Ueberschrift steht jetzt ein Knopf, der alle auf
einmal entfernt; einzeln geht weiterhin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:30:11 +02:00
GuidoandClaude Opus 5 6322b65306 fix: Verwendungszweck steht in der Buchungsliste vorn
Gross stand die Kategorie, klein der Zweck – gesucht wird aber nach dem
Zweck. Jetzt oben "Coop Pratteln", darunter klein "Lebensmittel"; das
Symbol trug die Kategorie ohnehin schon.

Ausserdem war der Knopf zum Entfernen eines rueckgaengig gemachten
Imports ein unbeschriftetes Papierkorb-Symbol und damit unauffindbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:28:39 +02:00
GuidoandClaude Opus 5 bc5349f193 feat: Zeilenfilter sichtbar, mit auswaehlbaren Werten
Der Filter steckte in einem zugeklappten Bereich und der Wert musste
blind getippt werden – entsprechend importierte man den Revolut-Auszug
mit allen Produkten in ein Konto, was den Saldo-Abgleich unmoeglich
macht.

Die Rohvorschau liefert jetzt je Spalte die wiederkehrenden Werte samt
Zeilenzahl. Hat eine Datei so eine Spalte, steht der Filter offen sichtbar
mit der Ueberschrift "Diese Datei enthaelt mehrere Konten", und der Wert
wird aus einer Liste gewaehlt ("Giro (530 Zeilen)").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:38:05 +02:00
GuidoandClaude Opus 5 d05f4915ad fix: Saldo-Abgleich erklaert die Abweichung verstaendlich
Die Meldung nannte nur Zahlen ("Laut Datei / errechnet / Differenz") und
sagte nicht, was zu tun ist. Sie beschreibt jetzt in Worten, was nicht
aufgeht und ab welcher Zeile beide auseinanderlaufen.

Dazu ein Hinweis auf die haeufigste Ursache, wenn kein Zeilenfilter
gesetzt ist: Revolut legt Giro, Erspartes und Anlagekonto in EINE Datei,
jedes mit eigener Kontostand-Spalte. Ohne Filter springt die Spalte
zwischen den Produkten und der Abgleich kann gar nicht aufgehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:27:14 +02:00
GuidoandClaude Opus 5 6c64dba47d test: neutrale Kategorie gilt auch fuer Einnahmen
Seit der Import keine Konten mehr verbindet, wird aus jedem Uebertrag ein
Paar: Ausgabe beim einen Konto, Einnahme beim anderen. Zaehlte nur die
Ausgabe nicht, stuende das Geld als Einnahme in der Auswertung und die
Monatsbilanz waere um den vollen Betrag zu gut.

Der Test belegt: eine neutrale Kategorie aus einer AUSGABEN-Gruppe laesst
sich einer Einnahme zuweisen, der Kontostand steigt, die Einnahmen der
Auswertung bleiben unveraendert – und ohne die Markierung zaehlt dieselbe
Buchung wieder mit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:24:51 +02:00
GuidoandClaude Opus 5 9d930b3484 feat: Skript teilt bestehende Uebertraege in zwei Buchungen
Damit der Altbestand zum neuen Verhalten passt: Jeder Uebertrag wird zu
einer Ausgabe beim Quellkonto und einer Einnahme beim Zielkonto, beide
mit der neutralen Kategorie. Die Salden bleiben dabei exakt gleich, nur
die Art aendert sich.

Ohne --anwenden nur ein Bericht; vor dem Schreiben wird die Datenbank
kopiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:22:10 +02:00
GuidoandClaude Opus 5 a4ea0e46db feat: Import verbindet keine Konten mehr automatisch
Das Raten des Gegenkontos war die Quelle fast aller Saldo-Fehler: falsch
geratene Ziele, verdrehte Richtungen und doppelt gebuchte Bewegungen,
sobald beide Ausz-uege eingelesen wurden. Zeilen ohne erkennbares Ziel
blieben ausserdem ungebucht, wodurch Geld unbemerkt fehlte.

Jede Zeile ist jetzt schlicht das, was im Auszug steht: Ausgabe oder
Einnahme. Fruehere Uebertrags-Schlagwoerter haengen stattdessen die
neutrale Kategorie an - der Saldo stimmt, die Statistik bleibt sauber.
Ein Uebertrag laesst sich weiterhin von Hand buchen.

Damit entfallen Gegenbuchungs-Suche, Zielkonto-Raten und der Zustand
"wartet auf Zielkonto".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:19:34 +02:00
GuidoandClaude Opus 5 b4cd5b7ca3 feat: Auszugs-Vergleich nennt die fehlenden Buchungen einzeln
Die Tagessummen sagen nur, dass etwas fehlt. Jetzt werden die Betraege je
Tag als Menge verglichen: was im Auszug steht und nicht in Certain, und
was Certain zusaetzlich hat – Letzteres mit Art und Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:04:11 +02:00
GuidoandClaude Opus 5 51af9eacff fix: Konto loeschen veraendert keine fremden Salden mehr
Beide Konto-Spalten einer Buchung hingen an ON DELETE CASCADE. Wurde ein
Zielkonto geloescht, verschwand die ganze Buchung – auch die Abgangsseite,
die einem anderen Konto gehoert. Dessen Saldo stieg dadurch stillschweigend
um den Uebertrag; beim Loeschen von 'Revolut investments' waren es 1'020.99.

Vor dem Loeschen werden solche Uebertraege jetzt zu gewoehnlicher Ausgabe
bzw. Einnahme auf dem verbleibenden Konto. Nur was allein am geloeschten
Konto haengt, geht mit ihm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:01:40 +02:00
GuidoandClaude Opus 5 e0955b79ee chore: Revolut-Auszug (nur Betraege) fuer den Saldo-Abgleich
Nur Produkt, Datum, Betrag, Gebuehr und Status – ohne Beschreibungen und
Empfaenger. Dient dazu, die gebuchten Daten auf dem Server Tag fuer Tag
gegen den Auszug zu vergleichen, und wird danach wieder entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:57:26 +02:00
GuidoandClaude Opus 5 0d9f0f88b0 feat: Vergleich der Buchungen mit dem Original-Auszug
Ein falscher Saldo sagt nur, DASS etwas fehlt. Das Skript rechnet je Tag
zusammen, was im Auszug steht und was in der Datenbank gelandet ist, und
listet die Tage auf, an denen beides auseinandergeht.

Gebuehren werden wie im Auszug verrechnet (Betrag minus Gebuehr); gegen
die Saldo-Spalte von Revolut geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:40:53 +02:00
GuidoandClaude Opus 5 6745f5490f feat: Skript raeumt falsche Uebertraege im Altbestand auf
Zwei Fehlerbilder: Uebertraege ohne Beleg (ein zu kurzer Erkennungs-Text
schlug mitten im Wort an) werden wieder zu Ausgabe/Einnahme, und zwar auf
dem Konto, aus dessen Auszug die Zeile stammt. Spiegel-Paare (dieselbe
Bewegung steht in beiden Auszuegen, etwa eine Revolut-Aufrundung) werden
gemeldet; welche Richtung bleibt, waehlt --behalte.

Ohne --anwenden aendert das Skript nichts, und vor dem Schreiben legt es
eine Kopie der Datenbank an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:34:35 +02:00
GuidoandClaude Opus 5 578439097e fix: Umlaute im Hinweis zu kurzen Erkennungstexten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:32:00 +02:00
GuidoandClaude Opus 5 1e7c1ec014 fix: Erkennungstext trifft nur noch an Wortgrenzen
Der Erkennungs-Text eines Kontos wurde als blosser Teilstring gesucht.
Am EUR-Konto stand „Euro" – der steckt auch in „PayPal Europe",
„Europa-Park" und „EUROBUS". Jede solche Zahlung wurde dadurch zum
Uebertrag auf dieses Konto, das Geld dort zusaetzlich gutgeschrieben und
der Saldo lief um mehr als tausend Euro daneben.

Gesucht wird jetzt nur an Wortgrenzen; eine Kartenendung wie „6543"
schlaegt damit auch nicht mehr mitten in einer laengeren Kontonummer an.
Das Kontoformular warnt zusaetzlich bei sehr kurzen Erkennungstexten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:31:41 +02:00
GuidoandClaude Opus 5 5dbfd6abc4 fix: Anfangssaldo gilt ab dem Stichtag
Der Stichtag eines Kontos wurde gespeichert und angezeigt, von der
Saldo-Berechnung aber ignoriert: sie addierte jede Buchung des Kontos,
auch die aus der Zeit vor dem Stichtag. Damit lief jeder Saldo um die
gesamte Vorgeschichte daneben.

Der Anfangssaldo beschreibt jetzt den Stand am ENDE des Stichtags – so
steht er auf dem Auszug. Buchungen bis einschliesslich dieses Tages
zaehlen nicht mehr zum Kontostand, bleiben aber in Liste und
Auswertungen. Beim Uebertrag wird jede Seite an ihrem eigenen Stichtag
gemessen; der Vermoegensverlauf folgt derselben Regel. Ohne Stichtag
zaehlt weiterhin alles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:26:21 +02:00
GuidoandClaude Opus 5 d88efebfeb feat: Diagnose zeigt Stichtag und Beispieltexte je Übertrag
Der Stichtag eines Kontos wird von der Saldo-Berechnung ignoriert – das
Skript weist jetzt aus, wie viel aus Buchungen bis zum Stichtag stammt
und trotzdem mitzählt. Die Übertrags-Matrix nennt dazu Beispieltexte,
Erkennungstexte der Konten und Transfer-Schlagwörter, damit sich ein
falscher Übertrag seinem Auslöser zuordnen lässt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:22:59 +02:00
GuidoandClaude Opus 5 e2387102c1 feat: Diagnose-Skript für Übertrag-Flüsse
Zeigt als Matrix, welches Konto an welches überträgt, dazu Überträge
ohne Zielkonto und Währungswechsel ohne Zielbetrag – Letztere schreiben
den Fremdbetrag unkonvertiert gut und verfälschen den Saldo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:02:06 +02:00
GuidoandClaude Opus 5 7fa15c2b86 feat: Diagnose-Skript schlüsselt Kontostände auf
Zeigt je Konto Anfangssaldo, Einnahmen, Ausgaben und beide Transfer-
Richtungen einzeln, dazu mehrfach gebuchte Auszüge und doppelte
Buchungen. Damit lässt sich ein falscher Saldo auf seine Ursache
zurückführen, statt sie zu erraten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:58:38 +02:00
GuidoandClaude Opus 4.8 3948d98273 feat: Diagnose-Skript für fehlende Schlagwörter
Die Kategorie-Liste zeigt nur manuelle, einer bestehenden Kategorie zugeordnete
Regeln. Bleibt sie leer, obwohl Regeln in der Datenbank stehen, sagt das Skript
warum: Es schlüsselt den Bestand nach source, kind und is_transfer auf, zählt
Regeln ohne Kategorie sowie solche, deren Kategorie nicht mehr existiert, und
nennt die Zahl der tatsächlich sichtbaren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 14:33:05 +02:00
GuidoandClaude Opus 4.8 ff95bc5344 feat: Backup-Prüfung nennt die neueste vollständige Sicherung
Bei 35 Sicherungen hilft eine blosse Liste wenig. Das Skript zeigt jetzt je
Sicherung Zeitpunkt, Schlagwörter, Budget-Vorlagen und wie viele Buchungen eine
Kategorie tragen – und nennt am Ende die neueste noch vollständige samt der
fertigen Befehle zum Zurückspielen. Tabellen, die es zum Sicherungszeitpunkt
noch nicht gab, erscheinen als '–' statt als Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 14:25:40 +02:00
GuidoandClaude Opus 4.8 33bbc535cd feat: Skript zum Prüfen der Datenbank-Sicherungen
Listet je Backup, wie viele Schlagwörter, Budget-Vorlagen und kategorisierte
Buchungen darin stecken. Nach einem Datenverlust sieht man damit auf einen Blick,
welche Sicherung noch vollständig ist – statt nach Zeitstempel zu raten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 14:23:12 +02:00
GuidoandClaude Opus 4.8 35d89b2b8f fix: Migrationen löschen keine abhängigen Daten mehr
Auf SQLite baut Alembic (render_as_batch) eine Tabelle um, indem es eine Kopie
anlegt und das Original löscht. Bei DROP TABLE führt SQLite ein implizites DELETE
aus – mit aktiven Fremdschlüsseln schlägt das per ON DELETE CASCADE auf abhängige
Tabellen durch.

Migration 0013 fügte `categories` nur ein Feld hinzu und vernichtete dabei alle
Schlagwörter und Budget-Vorlagen und setzte die Kategorie sämtlicher Buchungen
auf NULL. Reproduziert und behoben: Fremdschlüssel werden für die Dauer der
Migration abgeschaltet (vor der Transaktion, sonst ignoriert SQLite den Befehl)
und danach wieder aktiviert. Lässt sich das Abschalten nicht bestätigen, bricht
die Migration ab, statt Daten zu riskieren.

Regressionstest legt Daten auf Stand 0012 an, migriert auf head und prüft, dass
Schlagwörter, Budget-Vorlagen und Kategoriezuordnungen erhalten bleiben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 14:21:03 +02:00
GuidoandClaude Opus 4.8 af865a5c7b fix: neutrale Kategorien gelten für Ausgabe UND Einnahme
Kategorien erben ihre Art von der Gruppe, und die ist entweder Ausgabe oder
Einnahme. Für einen Kontoübertrag passt beides nicht: Auf dem einen Konto geht
Geld raus, auf dem anderen kommt es rein – man musste also zwei Kategorien in
zwei Gruppen anlegen.

Als „nicht in Auswertungen einrechnen" markierte Kategorien werden jetzt in
beiden Richtungen angeboten (Buchungsformular und Import-Prüfansicht), und ihre
Schlagwörter greifen unabhängig vom Vorzeichen. Eine Kategorie „Kontoübertrag"
in einer Ausgabe-Gruppe genügt damit für beide Seiten.

Test für beide Richtungen; 94 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 14:09:54 +02:00
GuidoandClaude Opus 4.8 7674e47f43 fix: Test für neutrale Kategorien misst relativ statt absolut
Die Testdatenbank lebt über den ganzen Lauf; im laufenden Monat liegen also
schon Buchungen anderer Tests. Absolute Erwartungswerte gingen allein durch,
im Gesamtlauf nicht. Jetzt zählt die Differenz vorher/nachher. 93 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 10:18:15 +02:00
GuidoandClaude Opus 4.8 5c8b53a995 feat: Kategorien aus Auswertungen ausnehmen (z. B. „Kontoübertrag")
Umbuchungen zwischen eigenen Konten sind keine Ausgabe – das Geld ist nicht weg,
es liegt woanders. Bisher blieb nur die Wahl zwischen einer echten Transfer-
Buchung (bildet die Wirklichkeit ab, braucht aber auf beiden Seiten passende
Regeln und schätzt bei Fremdwährung den Gegenwert) oder einer gewöhnlichen
Ausgabe, die Budget und Statistik verfälscht.

Kategorien haben jetzt den Schalter „Nicht in Auswertungen einrechnen": Die
Buchung wirkt weiter auf den Kontostand, bleibt aber aus Budget, Dashboard und
Auswertungen heraus. Gefiltert wird zentral in actual_expense_by_category und
income_by_category – damit greift es zugleich für Budget-Donut, Budget-
Fortschritt, Einnahmen/Ausgaben, Ausgaben-Anteile und die Budget-Seite.

Das ersetzt die Transfer-Paarung nicht, es ist der einfachere Weg: kein
Gegenkonto, keine Kursschätzung, dafür ohne Verbindung zwischen den beiden Seiten.

Migration 0013, Schalter im Kategorie-Dialog, Kennzeichnung in der Liste, Test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 10:17:20 +02:00
GuidoandClaude Opus 4.8 ec0aa62017 feat: Liniendiagramm mit Nulllinie, runden Schritten und wählbarem Zeitraum
Die Achse richtete sich rein nach den Daten: Beschriftungen wie „-3'065.06" sind
nicht ablesbar, und ohne feste Null sah man die Bewegung, aber nicht, ob man im
Plus oder Minus steht.

- Die Skala schließt die Null immer ein und ist optisch hervorgehoben
- Runde Schritte (1, 2, 2.5, 5 x Zehnerpotenz): 0 / 2'000 / 4'000 … statt
  krummer Werte; nie feiner als ein Rappen, sonst entstünden bei leerem Konto
  Zwischenwerte wie 0,0025
- Vermögensverlauf: Zeitraum als Karten-Option (30 T / 90 T / 6 M / 1 J)

Gilt für jedes Liniendiagramm, nicht nur den Vermögensverlauf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 09:59:36 +02:00
GuidoandClaude Opus 4.8 511cf1abe0 fix: Gegenbuchung nimmt den besten statt des erstbesten Treffers
Liegen mehrere Währungstausche im selben Zeitfenster, schnappte der zuerst
geprüfte Transfer der eigentlich gemeinten Zeile das Gegenstück weg – diese blieb
dann ohne Gegenkonto stehen. Jetzt gewinnt der Kandidat mit der geringsten
Datums- und Betragsabweichung.

Toleranz von 10 % auf 20 % erhöht: Wir schätzen die Gegenseite mit dem EZB-Kurs,
die Bank rechnet mit ihrem eigenen – und fehlt uns der Kurs, schätzen wir 1:1.
Ein echter Tausch lag damit schnell über 10 % daneben (CHF 150.00 -> EUR 165.60
sind 10,4 %), eine fremde Buchung trifft das Zeitfenster praktisch nie.

An den echten Auszügen: offene Zeilen im Revolut-EUR-Import von 3 auf 1 gesunken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 09:56:09 +02:00
GuidoandClaude Opus 4.8 7eed5ee6c4 feat: Ausgaben-Anteile wahlweise als Kreisdiagramm
Neue Karten-Option „Darstellung": Liste (Standard) oder Kreis. Dieselben Zahlen,
zwei Lesarten – die Liste nennt jeden Anteil genau, der Kreis zeigt die
Verteilung auf einen Blick. Nutzt den vorhandenen Donut, dessen Legende Betrag
und Anteil ohnehin schon ausweist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 09:46:48 +02:00
GuidoandClaude Opus 4.8 e10ccdf185 feat: Dashboard-Karte „Ausgaben-Anteile"
Zeigt für den laufenden Budget-Monat, wie sich die tatsächlichen Ausgaben
prozentual auf die Kategorien verteilen – absteigend sortiert, mit Balken in der
Kategoriefarbe, Prozentwert und Betrag.

Anders als der Budget-Donut hängt sie nicht am geplanten Budget: Sie zeigt reine
Ist-Ausgaben, auch wenn für den Monat nichts geplant wurde. Umschaltbar zwischen
Kategorien (Standard) und Gruppen.

Die Anteile rechnet die Oberfläche aus Betrag und Summe – gerundete Prozente vom
Server addierten sich sonst nicht auf 100 %. Wird die Karte schmal, gibt zuerst
der Betrag nach; ohne Kategorienamen sagt die Zeile nichts mehr aus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 09:30:14 +02:00
GuidoandClaude Opus 4.8 8d767e728a feat: Buchen-Knopf auch unter der Prüfliste + Verlauf aufräumen
- Bei mehreren hundert Zeilen ist der Knopf oben längst aus dem Bild gescrollt;
  unter der Liste steht jetzt derselbe.
- Rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen. Nur diese:
  solange ein Import steht, ist sein Eintrag die einzige Möglichkeit, seine
  Buchungen wieder loszuwerden – der Server lehnt das sonst ab.

Test für beide Fälle des neuen Endpunkts; 92 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 09:20:29 +02:00
GuidoandClaude Opus 4.8 ab5410ca6f fix: Saldo-Abgleich meldet Zeilen, die auf ein Gegenkonto warten
Eine Zeile mit „Gegenkonto wählen" wird nicht gebucht, der Abgleich zählte ihren
Betrag aber trotzdem mit und meldete „stimmt überein" – das Geld fehlte im Konto,
ohne dass es auffiel.

Duplikate und Gegenbuchungen zählen weiterhin mit (ihr Betrag steckt bereits in
einer früher gebuchten Buchung); wartende Zeilen zählen jetzt als 0 und tauchen
als erste Abweichung mit Datum und Empfänger auf.

Nachgestellt am echten Raiffeisen-Auszug: Zeigt die Transfer-Regel „Übertrag" auf
das importierte Konto selbst, bleibt die Gutschrift über 92.00 ungebucht; der
Abgleich nennt jetzt genau diese Zeile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 09:17:22 +02:00
GuidoandClaude Opus 4.8 c438d1ec50 feat: Bank-Vorlage für N26
Spaltenzuordnung aus einem echten Export (Betrag in Spalte „Amount (EUR)",
Partner als Empfänger, Payment Reference als Zweck, Partner-IBAN für die
Transfer-Erkennung). Eine Kontostand-Spalte liefert N26 nicht, dort bleibt der
Saldo-Abgleich also aus.

Nachgerechnet: 246 Zeilen, Summe -719.51; mit Anfangssaldo 872.95 ergibt das
exakt die tatsächlichen 153.44 EUR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 09:07:21 +02:00
GuidoandClaude Opus 4.8 4a6f7a0814 feat: Zeilenfilter für Auszüge mit mehreren Pockets + Gebühren-Richtung
Der Revolut-Auszug enthält ALLE Produkte in einer Datei – im CHF-Export stehen
533 Giro-Zeilen und 255 Zeilen des Ersparten, jedes mit eigenem Kontostand-
Verlauf. Ohne Trennung landeten beide auf demselben Konto und die Pocket-
Bewegungen zählten doppelt.

- Import-Vorlage kennt einen Zeilenfilter („nur Zeilen aus Spalte X mit Wert Y"),
  Migration 0012; damit bekommt jedes Pocket sein eigenes Konto
- Gebührenzeilen (Betrag 0.00) können Belastung ODER Erstattung sein – beide
  sehen identisch aus. Die Richtung kommt jetzt aus der Kontostand-Spalte;
  ohne sie bleibt die Belastung der Normalfall
- Tests für beides; 90 grün

Nachgerechnet an den echten Auszügen: Raiffeisen 223.37, Revolut CHF 61.88,
Erspartes 6.71, Revolut EUR 0.68 – alle vier stimmen zeilengenau mit der
Kontostand-Spalte überein.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 09:01:07 +02:00
GuidoandClaude Opus 4.8 595662e515 feat: ein Schlagwort darf mehrere Beträge haben
Ein Wort war je Art nur einmal erlaubt, also ließ sich genau ein Preis
hinterlegen. Im Personalrestaurant zahlt man aber mal 5.00 und mal 7.50 – der
zweite Preis ließ sich nicht erfassen.

Der Betrag gehört jetzt in den Unique-Schlüssel: „sv gruppe = 5.00" und
„sv gruppe = 7.50" sind zwei Regeln derselben Kategorie, dasselbe Wort OHNE
Betrag bleibt der Auffang für alle übrigen Beträge (und darf in einer anderen
Kategorie liegen). Betrags-Regeln greifen vor dem Auffang.

- Migration 0011 (Unique-Schlüssel um amount_cents erweitert)
- Konflikt nur noch bei gleichem Wort UND gleichem Betrag; gilt auch beim
  Umbenennen und für Transfer-Schlagwörter
- Test: zwei Preise treffen die Kategorie, ein dritter Betrag den Auffang; 88 grün

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 08:34:24 +02:00
GuidoandClaude Opus 4.8 187a1119a2 fix: Gegenbuchungs-Erkennung beachtet die Richtung
Die Suche prüfte nur Betrag und Zeitfenster. Ein erwarteter Transfer-EINGANG
passte damit auch auf eine gleich hohe AUSGABE: Die echte Ausgabe galt als
Gegenbuchung, wurde nicht gebucht und der Saldo lief um ihren Betrag zu hoch.

Jetzt muss das Vorzeichen zur Seite des Transfers passen (wo Geld ankam, kann
keine Ausgabe die Gegenseite sein). Test deckt genau diesen Fall ab; 87 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 08:31:18 +02:00
GuidoandClaude Opus 4.8 62eab06bdc feat: Gegenseite eines Übertrags braucht keine eigene Transfer-Regel
Bisher musste die Regel auf BEIDEN Auszügen ein fremdes Gegenkonto liefern. Beim
Empfangs-Auszug zeigt sie aber auf das Konto, das gerade importiert wird – damit
blieb die Zeile ohne Gegenkonto und wurde still als Einnahme gebucht.

- Gegenbuchungen werden über die Konto-Seite bestehender Transfers gesucht, nicht
  über das Konto-Paar. Der Empfangs-Auszug braucht damit keine Regel mehr; eine
  Regel auf der abgehenden Seite genügt für beide Dateien.
- Trifft eine Transfer-Regel, ohne dass ein Gegenkonto feststeht, wird die Zeile
  nicht mehr als Einnahme/Ausgabe gebucht, sondern in der Prüfansicht mit
  „Gegenkonto wählen" markiert – sonst zählt die Bewegung je nach Import-
  Reihenfolge doppelt.
- Tests für beide Reihenfolgen; 86 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 08:20:13 +02:00
GuidoandClaude Opus 4.8 cb4f3a8909 feat: Währungstausch wird über beide Auszüge als ein Transfer erkannt
Revolut tauscht Geld zwischen Währungskonten; im CHF-Auszug steht -100.00 CHF,
im EUR-Auszug +106.42 EUR. Die Gegenbuchungs-Erkennung verglich exakte Beträge
und griff deshalb nie – die Bewegung wurde zweimal gezählt.

- _find_counterpart erkennt die Gegenseite bei unterschiedlichen Währungen über
  Konto-Paar, Zeitfenster und einen plausiblen Betrag (10 % Toleranz, weil wir
  mit dem EZB-Kurs schätzen und die Bank ihren eigenen nutzt)
- die Gegenbuchung nennt den TATSÄCHLICHEN Betrag: er ersetzt beim Buchen die
  Schätzung im bereits gebuchten Transfer, damit beide Salden exakt stimmen
- Test über beide Auszüge: ein Transfer, keine Doppelzählung, EUR-Seite exakt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 08:13:59 +02:00
GuidoandClaude Opus 4.8 d97e08e913 fix: Datumsspalten mit Uhrzeit werden auch ohne passendes Format erkannt
Der Revolut-Export führt "2026-01-06 23:11:14" in der Datumsspalte. Wählte man
im Dropdown JJJJ-MM-TT, scheiterte das Parsen an der Uhrzeit – jede Zeile hatte
kein Datum und galt als ungültig ("137 Zeilen erkannt, 0 werden gebucht"),
obwohl die Auswahl richtig aussah.

- parse_date schneidet eine überzählige Uhrzeit ab und kommt auch damit klar,
  wenn das Format eine Uhrzeit erwartet, der Wert aber keine hat (ISO-T ebenso)
- Dropdown um die Varianten mit Uhrzeit ergänzt, damit die Bank-Vorlagen ihren
  echten Eintrag zeigen statt eines leeren Feldes
- Test: reines Datumsformat auf Datei mit Uhrzeit -> alle Zeilen erkannt, 84 grün

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 08:02:18 +02:00
GuidoandClaude Opus 4.8 e69be7f3d5 fix: negative Kontostände werden nicht mehr als positiv angezeigt
Die Money-Komponente gab den Betrag immer durch Math.abs() aus; das Vorzeichen
kam nur über das optionale sign-Prop. Kontostände (Konten-Seite und Dashboard-
Karte) übergeben keines – ein überzogenes Konto oder eine Kreditkartenschuld sah
dadurch positiv aus, während die Gesamtsumme korrekt negativ rechnete. Genau
dieser Widerspruch: alle Konten scheinbar im Plus, Gesamt im Minus.

Ohne sign steht der Wert für sich selbst und wird jetzt mit echtem Vorzeichen
(und rot) ausgegeben; mit sign bleibt es wie bisher, weil dort die Richtung aus
der Buchungsart kommt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 07:01:04 +02:00
GuidoandClaude Opus 4.8 5a747fbef4 feat: Saldo-Abgleich beim CSV-Import gegen die Kontostand-Spalte
Bisher merkte man einen Importfehler erst am falschen Endsaldo, ohne Hinweis auf
die Ursache. Enthält der Auszug eine laufende Kontostand-Spalte, gleicht der
Import jetzt jede Zeile dagegen ab: Die Spalte sagt, wie sich der Saldo
tatsächlich verändert hat, unsere Erkennung muss dazu passen.

- neue Spaltenzuordnung "Kontostand (laufend)"; Presets Raiffeisen/Revolut ergänzt
- Prüfansicht zeigt: stimmt überein ODER Differenz + erste abweichende Zeile
  (Datum, Empfänger, Betrag laut Bank vs. erkannt)
- meldet zusätzlich den Anfangssaldo laut Datei – häufigste Fehlerquelle
- rechnet mit dem tatsächlich gebuchten Betrag, damit auch nicht gebuchte Zeilen
  auffallen (z. B. nicht zugeordnete Gebührenspalte); Fremdwährungs-Zeilen
  gehören zu einem anderen Saldo-Verlauf und bleiben außen vor
- erkennt absteigend sortierte Auszüge
- Tests: stimmiger Auszug (ok) und fehlende Gebührenspalte (zeigt genau die
  Zeile). 83 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 06:58:33 +02:00
GuidoandClaude Opus 4.8 4520c7318f fix: Status-Prüfung beim Import ist sprachunabhängig
Die neue Statusprüfung ließ nur „COMPLETED" durch. Deutsche Bank-Exporte
schreiben aber „ABGESCHLOSSEN", wodurch keine einzige Zeile mehr buchbar war.

Statt einer Positivliste jetzt eine Sperrliste (storniert/ausstehend/abgelehnt/
fehlgeschlagen, jeweils deutsch und englisch); unbekannte Status werden gebucht,
damit keine echten Buchungen still verloren gehen.

Test mit echtem deutschem Revolut-Export inkl. reiner Gebührenzeile
(Betrag 0.00, Gebühr 10.99): Endsaldo trifft die Kontostand-Spalte. 81 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 06:41:32 +02:00
GuidoandClaude Opus 4.8 d725f5cf57 fix: Revolut-Import wertet Gebühr, Status und Währung aus
Das Revolut-Preset kannte die Spalten Gebühr/Währung/Status laut Kommentar,
der Parser hat sie aber nie gelesen. Folgen: Gebühren fehlten im Saldo,
stornierte (REVERTED) und schwebende Zeilen wurden mitgebucht, und Zeilen
fremder Währungs-Pockets landeten 1:1 im Konto – der Saldo lief dadurch zu hoch.

- Gebührenspalte mindert den Betrag (immer als Kosten)
- nur Zeilen mit Status COMPLETED (oder ohne Statusspalte) sind buchbar
- Zeilen in anderer Währung als das Zielkonto werden nicht gebucht
- Grund wird in der Prüfansicht als Badge angezeigt (storniert / Währung)
- Gebühr/Währung/Status in der Spaltenzuordnung wählbar; Revolut-Preset ergänzt
- Test mit echtem Revolut-Format (Gebühr, Storno, Fremdwährung), 80 grün

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 06:30:22 +02:00
GuidoandClaude Opus 4.8 01059b2bb2 fix: Buchungsliste zeigt alle Buchungen (Default-Limit 200 -> 5000)
Die Liste lud nur die 200 neuesten Buchungen, wodurch ältere Monate fehlten
(bei 254 importierten Zeilen reichte die Ansicht nur bis Juni zurück). Da die
Tabelle im Browser filtert und sortiert, werden nun standardmäßig alle Buchungen
geladen. Verifiziert am echten Auszug: 254 von 254, ab 05.01.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 06:02:22 +02:00
GuidoandClaude Opus 4.8 14b3e53685 fix: Duplikat-Erkennung als Multiset – echte gleich aussehende Buchungen bleiben
Bisher wurde eine zweite Buchung mit gleichem Tag/Betrag/Text (z. B. zwei
Gutschriften à 324 am selben Tag) fälschlich als Duplikat verworfen. Jetzt gilt
eine Zeile nur als Duplikat, wenn bereits GENAU SO VIELE identische Buchungen in
der DB stehen – ein erneuter Import derselben Datei bleibt weiterhin Duplikat-sicher.

- csv_import.detect_rows: existing/seen als Counter (Multiset) statt Set
- Tests angepasst + Re-Import-Test ergänzt; an echtem Raiffeisen-Auszug verifiziert
  (254 Zeilen, 0 fälschliche Duplikate, Saldo == Bank-Endsaldo)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 05:40:03 +02:00
GuidoandClaude Opus 4.8 338c21935b feat: Transfer-Erkennung symmetrisch (Eingänge) + Gegenbuchungs-Erkennung
- Enthält der Buchungstext den Erkennungs-Text (Art „Text") eines ANDEREN
  Kontos, wird auch ein Eingang als Transfer VON diesem Konto gesetzt – nicht
  nur die Ausgabe. Richtung ergibt sich aus dem Vorzeichen.
- Gegenbuchungs-Erkennung: existiert schon ein Transfer zwischen denselben
  Konten mit passendem Betrag (±5 Tage), wird die Gegenseite als „Gegenbuchung"
  markiert und nicht angehakt – so zählt dieselbe Bewegung beim Import beider
  Auszüge nicht doppelt (überschreibbar in der Prüfansicht).
- Tests: Eingang übers Konto-Tag, Gegenbuchung übersprungen (78 grün)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 20:52:31 +02:00
GuidoandClaude Opus 4.8 1f03483e5d fix: Konto umbenennen scheitert nicht mehr an Identifikator-UNIQUE
_set_identifiers glich die Liste per clear()+neu-Anlegen ab; SQLAlchemy fügte
dabei die (unveränderten) Identifikatoren vor dem Löschen ein und verletzte so
UNIQUE(account_id, kind, value_norm) -> Internal Server Error beim Speichern.
Jetzt als Diff: unveränderte behalten, nur fehlende hinzufügen / überzählige
entfernen. Betrifft alle Identifikator-Arten (IBAN/Nummer/Text).

Regressionstest: Umbenennen mit unverändertem Identifikator + Entfernen (76 grün).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 20:00:09 +02:00
GuidoandClaude Opus 4.8 c44892ff72 feat: Transfer-Schlagwort mit festem Zielkonto (z. B. REVOLUT -> Konto Revolut)
- Transfer-Regel kann direkt ein Zielkonto tragen (target_account_id); ohne ->
  weiterhin Auto-Erkennung aus dem Buchungstext (Konto-Texte)
- Migration 0010; Endpunkt aktualisiert bestehendes Wort statt Konflikt
- Import bevorzugt das feste Zielkonto, sonst _guess_target_account
- Konten-Seite: Transfer-Erkennung mit Zielkonto-Auswahl + Anzeige am Chip
- Test: festes Zielkonto greift ohne Konto-Texte (75 grün)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 19:06:24 +02:00
GuidoandClaude Opus 4.8 794a6aeb91 feat: Konto-Sicherheit gebündelt + Einstellungen aufgeräumt
- Neue Seite „Mein Konto" (/konto, für alle Nutzer): eigenes Passwort ändern
  (inkl. Richtlinien-Anzeige) + Zwei-Faktor verwalten; erreichbar über den
  Nutzer-Chip in der Seitenleisten-Fußzeile
- Einstellungen: 2FA-Karte entfernt (liegt jetzt bei „Mein Konto"), Karten in
  ein mehrspaltiges Raster gruppiert
- Menüpunkt „Einstellungen" unter die Überschrift „System" verschoben (für alle
  Nutzer sichtbar; „Benutzer" bleibt Admin-only)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 17:27:40 +02:00
GuidoandClaude Opus 4.8 39bf7ea11c feat: Import-UX runder + PDF-Seiten als Bild
- Bank-Vorlagen und eigene Vorlagen in EINEM Dropdown (optgroups, typ-getaggte
  Werte preset:/tpl:)
- Speichern überschreibt bei gleichem Namen (Rückfrage); geladene Vorlage belegt
  den Namen vor
- Spaltenvorschau wird automatisch erzeugt (kein „Datei einlesen"-Klick mehr)
- Gegen-IBAN in „Weitere Spalten (optional)" verschoben + erklärt; Grundzuordnung
  zeigt nur Datum/Betrag/Empfänger/Zweck
- Beleg-Modus zeigt die hochgeladenen PDF-Seiten als Bild + extrahierten Text

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 17:22:00 +02:00
GuidoandClaude Opus 4.8 5f88660bcc feat: PDF-Beleg-Parser blockbasiert (mehrzeilig) + Seiten als Bild
- Neuer Parser erkennt mehrzeilige Auszüge (N26 & Co.): Beschreibung → Value
  Date → Buchungsdatum → Betrag stehen auf eigenen Zeilen; Betrags-Zeile schließt
  eine Buchung ab, Kopf-/Fußzeilen (Name/Adresse/eigene IBAN/Seitenzahl/
  Spaltenköpfe) werden gefiltert, Gegen-IBAN und Zweck werden übernommen
- Alter Ein-Zeilen-Heuristik-Parser bleibt als Fallback
- /documents/preview liefert zusätzlich die Seiten als PNG-Data-URLs
  (PyMuPDF get_pixmap, kein poppler nötig)
- Tests: N26-artige mehrzeilige Fixture + Seiten-Rendering (74 grün);
  am echten 34-Seiten-Auszug 207 Buchungen erkannt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 17:14:12 +02:00
GuidoandClaude Opus 4.8 49b927e2eb feat: Budget – mehrere Monate nebeneinander + Kopieren Monat<->Vorlage
- Umschalter 1·2·3: Monate nebeneinander (responsives Raster, auf schmalem
  Schirm gestapelt); prev/next verschiebt das ganze Fenster
- Je Monat: „Als Standard festlegen" (Monat -> Vorlage) und „Standard
  übernehmen" (Vorlage -> Monat, überschreibt), plus Zurücksetzen
- Backend: copy_month_to_template / copy_template_to_month + Endpunkte
  POST /budget/template/from-month/{label} und /budget/{label}/from-template
- Test fürs Kopieren; globale .grow-Utility ergänzt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 17:07:49 +02:00
GuidoandClaude Opus 4.8 8d0fbdfe2b feat: Dashboard – Donut ohne Überlauf, Achse lesbar, Karten-Optionen
- Budget-Donut: Mittelbetrag passt sich der Länge an (kein Hineinlaufen in den
  Ring bei 4-stelligen Beträgen)
- Vermögensverlauf: Y-Achse ohne Währungssymbol (formatAmount) + etwas mehr
  linker Rand → „CHF …" wird nicht mehr abgeschnitten
- Karten-Optionen: im Anordnen-Modus je Karte einstellbar; Budget-Donut und
  Budget-Fortschritt können nach Gruppen ODER Kategorien aufschlüsseln
  (?group_by=), Auswahl wird im Layout (props) mitgespeichert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 16:58:32 +02:00
GuidoandClaude Opus 4.8 d1cab19264 feat: gleiches Schlagwort für Einnahme UND Ausgabe erlaubt (Art je Regel)
- CategorizationRule bekommt `kind` (expense|income|transfer); Unique jetzt
  (owner, match_field, pattern, kind) statt ohne kind
- Konfliktprüfung/Anlage (add/update Keyword, Transfer-Keyword, gelernte Regeln)
  arbeiten kind-bewusst: dasselbe Wort je einmal pro Art möglich
- Migration 0009 mit Backfill (kind aus Kategorie-Art bzw. is_transfer)
- Tests angepasst: quer über Arten erlaubt, gleiche Art weiterhin Konflikt (71 grün)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 16:37:35 +02:00
ScarriffleandClaude Opus 5 64cbbc8cf6 feat: Kategorien mit Schlagwoertern sind in der Liste erkennbar
Der Schlagwort-Knopf sah bei jeder Kategorie gleich aus - man musste jede
einzeln oeffnen, um zu sehen, wo ueberhaupt etwas hinterlegt ist. Belegte
Kategorien tragen den Knopf jetzt in Akzentfarbe und mit der Anzahl daneben,
der Tooltip nennt sie ausgeschrieben.

Die Zahl kommt als keyword_count an der Kategorie mit, ermittelt in einer
Aggregat-Abfrage fuer die ganze Liste. Ein Abruf je Kategorie waere dafuer
unverhaeltnismaessig gewesen. Gefuellt wird das Feld nur beim Auflisten;
beim Anlegen und Aendern einer Gruppe bleibt es bei 0, wo es ohnehin nichts
aussagt.

Das Popup laedt beim Schliessen die Liste neu, damit die Zahl nach dem
Bearbeiten stimmt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:19:42 +02:00
ScarriffleandClaude Opus 5 df5bfec331 fix: Ankreuzfelder zentral von der Textfeld-Regel ausnehmen
In der Passwortrichtlinie stand das Haekchen mittig und der Text ganz rechts.
Ursache war die globale Regel "input, select { width: 100% }": sie erwischt
auch Ankreuzfelder und zieht sie ueber die volle Breite. Vier Stellen im
Code umgingen das bereits einzeln (.checklist, .dt-pop-opt, .checkline,
.datatable td) - eine fuenfte Umgehung waere die falsche Antwort gewesen.

Ankreuz- und Auswahlfelder sind jetzt zentral ausgenommen, die vier
Einzelfaelle entfallen. Nebeneffekt: sie tragen ueberall dieselbe
Akzentfarbe (bisher nur an zwei Stellen) und sitzen in Tabellenzellen
mittig, statt durch das geerbte margin-top um 5px nach unten gedrueckt zu
werden.

Die Karte selbst nutzt jetzt .checkline, das vorhandene Muster fuer
"Ankreuzfeld plus Text"; .field-inline richtet an der Grundlinie aus und
war hier von vornherein das falsche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:10:56 +02:00
ScarriffleandClaude Opus 5 cdf21474cf feat: Schlagwort per Doppelklick oben in der Zeile bearbeiten
Bisher oeffnete der Doppelklick ein Eingabefeld im Chip selbst. Dort war nur
Platz fuer das Wort - der Betrag liess sich gar nicht aendern, weil die
Inline-Bearbeitung bewusst nur { word } geschickt hat.

Jetzt laedt der Doppelklick Wort und Betrag in dieselbe Zeile, in der auch
neue Schlagwoerter entstehen. Der Knopf wechselt auf ein Haekchen, daneben
steht Abbrechen (auch per Escape), und der bearbeitete Chip ist markiert.
Gespeichert wird per PATCH mit ausdruecklichem amount_cents, damit ein
geleertes Betragsfeld den Betrag auch wirklich loescht; ein geparstes 0 wird
zu null, weil das Schema gt=0 verlangt. Ueber POST ginge es nicht: das
antwortet bei vorhandenem Wort mit geaendertem Betrag mit 409.

Tests decken jetzt ab, dass Betrag mitgeaendert wird, beim Weglassen stehen
bleibt und bei ausdruecklichem null verschwindet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:49:43 +02:00
ScarriffleandClaude Opus 5 e503baa8a6 fix: sprechende Dateinamen fuer alle Exporte
Im Downloads-Ordner lagen "certain-finance-export_....json" und
"certain-finance-backup_....json" nebeneinander, ohne dass man sah, welche
Datei sich zurueckladen laesst. Alle Downloads heissen jetzt
certain-finance_<art>_<datum>_<uhrzeit>.<endung>, wobei die Art es sagt:
vollbackup, vollbackup-system, datenuebersicht, buchungen, datenbank. Das
persoenliche Vollbackup traegt zusaetzlich den Benutzernamen.

Den Namen vergibt jetzt allein der Server im Content-Disposition-Kopf; das
Frontend liest ihn von dort. Vorher erfanden beide Seiten ihn getrennt und
kamen auf unterschiedliche Ergebnisse (Bindestrich gegen Unterstrich, UTC
gegen Ortszeit). Zeitstempel ist Ortszeit: wer abends um 20 Uhr exportiert,
soll keine Datei von 18 Uhr finden.

Passend dazu heisst der Knopf "Alle Daten (JSON)" nun "Datenuebersicht
(JSON)", und der Hinweis darueber sagt ausdruecklich, dass sich diese
Dateien nicht zurueckladen lassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:13:39 +02:00
243 changed files with 44664 additions and 918 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "backend",
"runtimeExecutable": "cmd",
"runtimeArgs": [
"/c",
"cd backend && .venv\\Scripts\\python.exe -m uvicorn app.main:app --port 8000"
],
"port": 8000
},
{
"name": "web",
"runtimeExecutable": "cmd",
"runtimeArgs": ["/c", "cd web && npm run dev"],
"port": 5173
}
]
}
+6 -2
View File
@@ -1,5 +1,5 @@
# Python # Python (auch Neben-Umgebungen wie .venv-test)
backend/.venv/ backend/.venv*/
**/__pycache__/ **/__pycache__/
*.pyc *.pyc
@@ -21,3 +21,7 @@ deploy/.env
.DS_Store .DS_Store
.vscode/ .vscode/
.idea/ .idea/
# Xcode
**/xcuserdata/
DerivedData/
+9
View File
@@ -0,0 +1,9 @@
# Vorübergehende Abgleichs-Datei
`revolut-chf-nur-betraege.csv` ist der Revolut-Auszug, reduziert auf die fünf
Spalten, die `backend/scripts/pruefe_gegen_auszug.py` braucht: Produkt, Datum,
Betrag, Gebühr, Status. Beschreibungen und Empfänger sind **nicht** enthalten.
Sie liegt hier nur, um den Saldo-Abgleich auf dem Server nachvollziehen zu
können, und kann danach gelöscht werden. Hinweis: Ein `git rm` entfernt sie aus
dem Arbeitsverzeichnis, nicht aus der Git-Historie.
+789
View File
@@ -0,0 +1,789 @@
Produkt,Datum des Abschlusses,Betrag,Gebühr,Status
Giro,2026-01-06 23:05:01,100.00,0.00,ABGESCHLOSSEN
Giro,2026-01-06 23:08:36,150.00,0.00,ABGESCHLOSSEN
Giro,2026-01-06 23:08:58,-100.00,0.00,ABGESCHLOSSEN
Giro,2026-01-06 23:09:35,-31.90,0.00,ABGESCHLOSSEN
Giro,2026-01-06 23:10:22,-118.00,0.00,ABGESCHLOSSEN
Giro,2026-01-08 15:53:25,150.00,0.00,ABGESCHLOSSEN
Giro,2026-01-09 08:34:12,-50.00,0.00,ABGESCHLOSSEN
Giro,2026-01-11 03:17:01,0.00,10.99,ABGESCHLOSSEN
Giro,2026-01-22 23:18:19,-0.62,0.00,ABGESCHLOSSEN
Giro,2026-01-23 21:51:34,200.00,0.00,ABGESCHLOSSEN
Giro,2026-01-23 21:52:04,-214.00,0.00,ABGESCHLOSSEN
Giro,2026-01-24 12:13:44,-74.38,0.00,ABGESCHLOSSEN
Giro,2026-01-27 08:40:42,200.00,0.00,ABGESCHLOSSEN
Giro,2026-01-27 08:40:59,-200.00,0.00,ABGESCHLOSSEN
Giro,2026-01-29 07:55:31,100.00,0.00,ABGESCHLOSSEN
Giro,2026-01-29 10:21:45,-7.90,0.00,ABGESCHLOSSEN
Giro,2026-01-31 22:52:29,-50.00,0.00,ABGESCHLOSSEN
Giro,2026-02-01 14:37:59,-40.00,0.00,ABGESCHLOSSEN
Giro,2026-02-05 06:52:44,200.00,0.00,ABGESCHLOSSEN
Giro,2026-02-05 06:53:05,-200.00,0.00,ABGESCHLOSSEN
Giro,2026-02-11 01:53:11,0.00,10.99,ABGESCHLOSSEN
Giro,2026-02-11 02:00:14,0.48,0.00,ABGESCHLOSSEN
Giro,2026-02-15 10:12:37,100.00,0.00,ABGESCHLOSSEN
Giro,2026-02-16 11:44:57,-0.13,0.00,ABGESCHLOSSEN
Giro,2026-02-16 21:01:29,200.00,0.00,ABGESCHLOSSEN
Giro,2026-02-16 21:01:53,-137.06,0.00,ABGESCHLOSSEN
Giro,2026-02-17 14:49:34,-6.87,0.00,ABGESCHLOSSEN
Giro,2026-02-20 19:54:32,-90.00,0.00,ABGESCHLOSSEN
Giro,2026-02-22 00:53:01,-0.60,0.00,ABGESCHLOSSEN
Giro,2026-02-23 14:30:43,-6.40,0.00,ABGESCHLOSSEN
Giro,2026-02-25 22:38:31,600.00,0.00,ABGESCHLOSSEN
Giro,2026-02-25 22:39:06,-250.00,0.00,ABGESCHLOSSEN
Giro,2026-02-25 22:39:39,-400.00,0.00,ABGESCHLOSSEN
Giro,2026-02-27 17:30:31,1000.00,0.00,ABGESCHLOSSEN
Giro,2026-02-28 20:53:45,-300.00,0.00,ABGESCHLOSSEN
Giro,2026-02-28 20:57:27,-80.00,0.00,ABGESCHLOSSEN
Giro,2026-03-01 22:29:19,-0.50,0.00,ABGESCHLOSSEN
Giro,2026-03-02 02:59:43,-311.65,0.00,ABGESCHLOSSEN
Giro,2026-03-02 12:23:17,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-02 14:32:29,-36.50,0.00,ABGESCHLOSSEN
Giro,2026-03-03 12:10:14,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-03 13:46:18,-0.10,0.00,ABGESCHLOSSEN
Giro,2026-03-03 15:41:39,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-03-04 12:32:28,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-04 14:07:54,-44.90,0.00,ABGESCHLOSSEN
Giro,2026-03-04 14:15:50,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-03-05 12:12:22,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-05 13:44:47,-35.90,0.00,ABGESCHLOSSEN
Giro,2026-03-05 15:29:35,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-03-05 17:51:07,-0.65,0.00,ABGESCHLOSSEN
Giro,2026-03-06 12:26:46,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-06 16:34:22,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-03-06 16:35:55,-15.35,0.00,ABGESCHLOSSEN
Giro,2026-03-07 17:02:02,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-03-08 12:35:06,-0.51,0.00,ABGESCHLOSSEN
Giro,2026-03-09 12:33:54,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-10 12:19:07,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-10 14:39:13,-64.49,0.00,ABGESCHLOSSEN
Giro,2026-03-10 14:42:36,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-03-11 03:32:54,0.00,10.99,ABGESCHLOSSEN
Giro,2026-03-11 14:39:14,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-03-13 09:49:50,250.00,0.00,ABGESCHLOSSEN
Giro,2026-03-13 12:09:24,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-13 14:31:31,44.98,0.00,ABGESCHLOSSEN
Giro,2026-03-14 06:59:13,-0.25,0.00,ABGESCHLOSSEN
Giro,2026-03-14 16:24:40,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-03-15 15:03:17,-13.75,0.00,ABGESCHLOSSEN
Giro,2026-03-16 02:38:04,-332.15,0.00,ABGESCHLOSSEN
Giro,2026-03-16 14:07:07,300.00,0.00,ABGESCHLOSSEN
Giro,2026-03-16 14:08:20,-150.00,0.00,ABGESCHLOSSEN
Giro,2026-03-16 20:10:07,14.00,0.00,ABGESCHLOSSEN
Giro,2026-03-16 20:12:14,121.42,1.00,ABGESCHLOSSEN
Giro,2026-03-18 21:06:13,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-20 15:14:47,-7.00,0.00,ABGESCHLOSSEN
Giro,2026-03-22 00:53:05,-0.61,0.00,ABGESCHLOSSEN
Giro,2026-03-23 09:52:13,-6.39,0.00,ABGESCHLOSSEN
Giro,2026-03-23 11:37:09,-50.00,0.00,ABGESCHLOSSEN
Giro,2026-03-24 09:07:28,-0.50,0.00,ABGESCHLOSSEN
Giro,2026-03-24 11:58:48,-0.20,0.00,ABGESCHLOSSEN
Giro,2026-03-24 12:01:21,-0.40,0.00,ABGESCHLOSSEN
Giro,2026-03-25 12:09:47,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-25 14:22:16,-58.80,0.00,ABGESCHLOSSEN
Giro,2026-03-25 14:43:44,-4.50,0.00,ABGESCHLOSSEN
Giro,2026-03-25 15:13:19,-7.60,0.00,ABGESCHLOSSEN
Giro,2026-03-26 06:13:52,-39.67,0.00,ABGESCHLOSSEN
Giro,2026-03-26 12:29:53,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-26 13:25:23,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-03-26 18:08:29,-0.78,0.00,ABGESCHLOSSEN
Giro,2026-03-26 18:09:19,-0.18,0.00,ABGESCHLOSSEN
Giro,2026-03-26 18:38:13,-0.64,0.00,ABGESCHLOSSEN
Giro,2026-03-26 18:51:15,-0.14,0.00,ABGESCHLOSSEN
Giro,2026-03-26 19:55:19,500.00,0.00,ABGESCHLOSSEN
Giro,2026-03-26 19:56:25,-0.49,0.00,ABGESCHLOSSEN
Giro,2026-03-27 10:51:30,-19.51,0.00,ABGESCHLOSSEN
Giro,2026-03-27 11:50:22,-0.40,0.00,ABGESCHLOSSEN
Giro,2026-03-27 12:59:06,-0.90,0.00,ABGESCHLOSSEN
Giro,2026-03-27 14:16:41,-17.22,0.00,ABGESCHLOSSEN
Giro,2026-03-27 14:22:52,-25.82,0.00,ABGESCHLOSSEN
Giro,2026-03-27 14:52:27,-13.36,0.00,ABGESCHLOSSEN
Giro,2026-03-27 14:52:49,-8.86,0.00,ABGESCHLOSSEN
Giro,2026-03-27 15:06:10,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-03-28 16:57:51,-18.60,0.00,ABGESCHLOSSEN
Giro,2026-03-28 16:58:18,-11.10,0.00,ABGESCHLOSSEN
Giro,2026-03-29 15:21:56,1600.00,0.00,ABGESCHLOSSEN
Giro,2026-03-29 15:23:03,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-29 23:42:39,0.00,-10.99,ABGESCHLOSSEN
Giro,2026-03-29 23:42:39,0.00,18.99,ABGESCHLOSSEN
Giro,2026-03-31 08:53:49,-0.60,0.00,ABGESCHLOSSEN
Giro,2026-03-31 08:54:52,-0.60,0.00,ABGESCHLOSSEN
Giro,2026-03-31 08:55:56,-0.60,0.00,ABGESCHLOSSEN
Giro,2026-03-31 08:56:55,-0.60,0.00,ABGESCHLOSSEN
Giro,2026-03-31 12:24:31,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-03-31 15:56:58,-2.40,0.00,ABGESCHLOSSEN
Giro,2026-03-31 15:56:58,-2.40,0.00,ABGESCHLOSSEN
Giro,2026-03-31 15:56:58,-2.40,0.00,ABGESCHLOSSEN
Giro,2026-03-31 16:32:12,-0.55,0.00,ABGESCHLOSSEN
Giro,2026-03-31 16:37:54,-1999.00,0.00,ABGESCHLOSSEN
Giro,2026-03-31 17:30:14,-2.40,0.00,ABGESCHLOSSEN
Giro,2026-04-01 12:10:56,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-01 16:47:24,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-01 16:50:25,-9.45,0.00,ABGESCHLOSSEN
Giro,2026-04-02 08:13:54,808.00,0.00,ABGESCHLOSSEN
Giro,2026-04-02 08:14:15,-400.00,0.00,ABGESCHLOSSEN
Giro,2026-04-02 14:28:21,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-02 17:17:20,-0.90,0.00,ABGESCHLOSSEN
Giro,2026-04-02 18:39:01,-265.98,0.00,ABGESCHLOSSEN
Giro,2026-04-03 14:21:13,-13.10,0.00,ABGESCHLOSSEN
Giro,2026-04-05 13:33:31,-30.00,0.00,ABGESCHLOSSEN
Giro,2026-04-07 08:44:07,-100.00,0.00,ABGESCHLOSSEN
Giro,2026-04-07 11:01:40,500.00,0.00,ABGESCHLOSSEN
Giro,2026-04-07 11:01:58,-250.00,0.00,ABGESCHLOSSEN
Giro,2026-04-07 12:13:20,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-08 12:13:34,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-08 17:41:18,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-09 12:20:29,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-09 15:29:19,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-10 12:25:07,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-10 17:35:40,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-10 18:06:55,-0.50,0.00,ABGESCHLOSSEN
Giro,2026-04-11 16:55:02,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-11 16:56:26,-12.50,0.00,ABGESCHLOSSEN
Giro,2026-04-11 17:05:47,635.00,0.00,ABGESCHLOSSEN
Giro,2026-04-12 20:14:08,-0.84,0.00,ABGESCHLOSSEN
Giro,2026-04-13 12:26:16,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-13 13:44:44,-326.05,0.00,ABGESCHLOSSEN
Giro,2026-04-13 16:16:43,-17.16,0.00,ABGESCHLOSSEN
Giro,2026-04-13 18:11:28,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-14 12:05:10,-38.00,0.00,ABGESCHLOSSEN
Giro,2026-04-14 12:27:49,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-14 13:38:24,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-14 15:24:58,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-14 17:51:29,-0.45,0.00,ABGESCHLOSSEN
Giro,2026-04-15 03:03:40,-24.55,0.00,ABGESCHLOSSEN
Giro,2026-04-15 12:11:01,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-15 15:37:55,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-16 09:18:01,-0.80,0.00,ABGESCHLOSSEN
Giro,2026-04-16 10:35:07,-0.80,0.00,ABGESCHLOSSEN
Giro,2026-04-16 12:07:12,-0.50,0.00,ABGESCHLOSSEN
Giro,2026-04-16 12:52:23,-0.05,0.00,ABGESCHLOSSEN
Giro,2026-04-16 14:38:34,-9.00,0.00,ABGESCHLOSSEN
Giro,2026-04-16 14:39:45,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-16 17:13:32,-4.50,0.00,ABGESCHLOSSEN
Giro,2026-04-17 11:23:52,-0.26,0.00,ABGESCHLOSSEN
Giro,2026-04-17 14:21:46,-10.20,0.00,ABGESCHLOSSEN
Giro,2026-04-17 14:21:53,-11.20,0.00,ABGESCHLOSSEN
Giro,2026-04-17 14:41:04,-6.95,0.00,ABGESCHLOSSEN
Giro,2026-04-17 19:15:51,-110.00,0.00,ABGESCHLOSSEN
Giro,2026-04-17 20:57:51,-50.00,0.00,ABGESCHLOSSEN
Giro,2026-04-18 16:55:39,-0.33,0.00,ABGESCHLOSSEN
Giro,2026-04-18 19:33:01,-0.05,0.00,ABGESCHLOSSEN
Giro,2026-04-19 09:36:13,-22.74,0.00,ABGESCHLOSSEN
Giro,2026-04-19 10:39:50,-15.67,0.00,ABGESCHLOSSEN
Giro,2026-04-19 10:54:04,-0.70,0.00,ABGESCHLOSSEN
Giro,2026-04-19 14:03:57,-16.95,0.00,ABGESCHLOSSEN
Giro,2026-04-19 20:24:50,-0.04,0.00,ABGESCHLOSSEN
Giro,2026-04-20 11:10:56,-8.30,0.00,ABGESCHLOSSEN
Giro,2026-04-20 12:19:07,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-20 13:38:46,-22.96,0.00,ABGESCHLOSSEN
Giro,2026-04-21 12:28:05,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-21 14:38:51,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-22 01:00:05,-0.57,0.00,ABGESCHLOSSEN
Giro,2026-04-22 02:33:54,-36.55,0.00,ABGESCHLOSSEN
Giro,2026-04-22 05:44:52,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-22 12:05:10,-6.43,0.00,ABGESCHLOSSEN
Giro,2026-04-22 12:07:52,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-22 22:25:17,-0.33,0.00,ABGESCHLOSSEN
Giro,2026-04-23 03:34:57,-41.67,0.00,ABGESCHLOSSEN
Giro,2026-04-23 05:51:39,-6.00,0.00,ABGESCHLOSSEN
Giro,2026-04-23 12:22:59,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-23 16:57:05,-0.30,0.00,ABGESCHLOSSEN
Giro,2026-04-24 05:38:03,-11.70,0.00,ABGESCHLOSSEN
Giro,2026-04-24 05:38:10,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-24 12:23:32,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-24 14:57:44,-0.25,0.00,ABGESCHLOSSEN
Giro,2026-04-24 15:02:08,-0.40,0.00,ABGESCHLOSSEN
Giro,2026-04-24 15:04:18,-0.20,0.00,ABGESCHLOSSEN
Giro,2026-04-24 18:03:22,500.00,0.00,ABGESCHLOSSEN
Giro,,-98.08,0.00,STORNIERT
Giro,2026-04-25 03:32:21,-0.92,0.00,ABGESCHLOSSEN
Giro,2026-04-25 05:36:50,-10.75,0.00,ABGESCHLOSSEN
Giro,2026-04-25 05:36:52,-2.80,0.00,ABGESCHLOSSEN
Giro,2026-04-25 05:36:55,-4.60,0.00,ABGESCHLOSSEN
Giro,2026-04-25 05:36:55,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-25 12:49:00,-0.25,0.00,ABGESCHLOSSEN
Giro,2026-04-25 12:59:57,-0.88,0.00,ABGESCHLOSSEN
Giro,2026-04-26 05:33:48,-14.75,0.00,ABGESCHLOSSEN
Giro,2026-04-26 05:33:55,-98.12,0.00,ABGESCHLOSSEN
Giro,2026-04-27 12:22:42,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-27 13:32:46,500.00,0.00,ABGESCHLOSSEN
Giro,2026-04-27 13:32:56,-400.00,0.00,ABGESCHLOSSEN
Giro,2026-04-28 06:10:26,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-28 12:25:23,-0.50,0.00,ABGESCHLOSSEN
Giro,2026-04-28 18:05:47,-120.00,0.00,ABGESCHLOSSEN
Giro,2026-04-29 02:08:22,0.00,18.99,ABGESCHLOSSEN
Giro,2026-04-29 05:46:38,-3.50,0.00,ABGESCHLOSSEN
Giro,2026-04-29 12:12:48,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-04-29 20:08:53,-0.28,0.00,ABGESCHLOSSEN
Giro,2026-04-30 05:42:09,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-04-30 07:27:56,500.00,0.00,ABGESCHLOSSEN
Giro,2026-04-30 07:28:17,-300.00,0.00,ABGESCHLOSSEN
Giro,2026-04-30 07:45:22,-0.80,0.00,ABGESCHLOSSEN
Giro,2026-04-30 11:23:32,-0.30,0.00,ABGESCHLOSSEN
Giro,2026-04-30 11:26:31,-0.30,0.00,ABGESCHLOSSEN
Giro,2026-04-30 15:52:51,-19.20,0.00,ABGESCHLOSSEN
Giro,2026-04-30 16:12:13,-8.72,0.00,ABGESCHLOSSEN
Giro,2026-04-30 17:44:44,-0.19,0.00,ABGESCHLOSSEN
Giro,2026-05-01 05:40:02,-26.70,0.00,ABGESCHLOSSEN
Giro,2026-05-01 05:40:05,-10.70,0.00,ABGESCHLOSSEN
Giro,2026-05-02 09:17:57,-6.81,0.00,ABGESCHLOSSEN
Giro,2026-05-04 04:28:56,-0.77,0.00,ABGESCHLOSSEN
Giro,2026-05-04 18:45:45,-0.10,0.00,ABGESCHLOSSEN
Giro,2026-05-05 05:46:28,-18.90,0.00,ABGESCHLOSSEN
Giro,2026-05-05 05:46:44,-113.23,0.00,ABGESCHLOSSEN
Giro,2026-05-05 12:11:03,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-05 14:51:21,-0.35,0.00,ABGESCHLOSSEN
Giro,2026-05-06 06:06:44,-19.65,0.00,ABGESCHLOSSEN
Giro,2026-05-06 06:06:50,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-05-06 12:11:47,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-07 05:48:07,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-05-07 12:24:35,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-08 06:51:57,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-05-08 12:56:33,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-09 06:14:42,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-05-09 14:05:03,-0.60,0.00,ABGESCHLOSSEN
Giro,2026-05-10 03:06:49,-17.40,0.00,ABGESCHLOSSEN
Giro,2026-05-11 12:13:29,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-12 05:57:05,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-05-12 12:11:58,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-13 06:02:32,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-05-13 11:48:16,-0.95,0.00,ABGESCHLOSSEN
Giro,2026-05-13 12:59:20,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-13 19:11:45,-0.17,0.00,ABGESCHLOSSEN
Giro,2026-05-13 20:38:44,-0.66,0.00,ABGESCHLOSSEN
Giro,2026-05-14 06:01:53,-19.05,0.00,ABGESCHLOSSEN
Giro,2026-05-14 06:02:02,-10.00,0.00,ABGESCHLOSSEN
Giro,2026-05-14 10:04:25,-7.34,0.00,ABGESCHLOSSEN
Giro,2026-05-14 15:00:51,-0.85,0.00,ABGESCHLOSSEN
Giro,2026-05-14 15:57:46,-18.83,0.00,ABGESCHLOSSEN
Giro,2026-05-14 16:40:47,-0.99,0.00,ABGESCHLOSSEN
Giro,2026-05-14 16:56:24,-0.20,0.00,ABGESCHLOSSEN
Giro,2026-05-15 13:46:19,-0.45,0.00,ABGESCHLOSSEN
Giro,2026-05-15 14:38:44,-0.93,0.00,ABGESCHLOSSEN
Giro,2026-05-15 15:37:15,-0.34,0.00,ABGESCHLOSSEN
Giro,2026-05-15 16:36:49,-9.15,0.00,ABGESCHLOSSEN
Giro,2026-05-15 16:37:02,-9.01,0.00,ABGESCHLOSSEN
Giro,2026-05-15 16:37:04,-8.80,0.00,ABGESCHLOSSEN
Giro,2026-05-15 17:21:07,-0.21,0.00,ABGESCHLOSSEN
Giro,2026-05-15 17:58:24,-0.33,0.00,ABGESCHLOSSEN
Giro,2026-05-16 06:10:13,-7.79,0.00,ABGESCHLOSSEN
Giro,2026-05-16 12:48:49,-0.12,0.00,ABGESCHLOSSEN
Giro,2026-05-16 12:49:41,-0.67,0.00,ABGESCHLOSSEN
Giro,2026-05-16 13:53:23,-21.07,0.00,ABGESCHLOSSEN
Giro,2026-05-16 13:53:28,-30.66,0.00,ABGESCHLOSSEN
Giro,2026-05-16 13:53:29,-7.67,0.00,ABGESCHLOSSEN
Giro,2026-05-16 13:53:35,-11.55,0.00,ABGESCHLOSSEN
Giro,2026-05-16 14:06:38,-0.08,0.00,ABGESCHLOSSEN
Giro,2026-05-16 14:36:40,-0.96,0.00,ABGESCHLOSSEN
Giro,2026-05-16 15:22:19,-0.52,0.00,ABGESCHLOSSEN
Giro,2026-05-16 21:22:56,-0.76,0.00,ABGESCHLOSSEN
Giro,2026-05-17 05:44:20,-5.04,0.00,ABGESCHLOSSEN
Giro,2026-05-17 09:00:32,-0.12,0.00,ABGESCHLOSSEN
Giro,2026-05-17 09:30:32,-5.48,0.00,ABGESCHLOSSEN
Giro,2026-05-17 09:30:35,-22.92,0.00,ABGESCHLOSSEN
Giro,2026-05-17 09:30:36,-4.33,0.00,ABGESCHLOSSEN
Giro,2026-05-17 09:30:42,-7.24,0.00,ABGESCHLOSSEN
Giro,2026-05-17 15:29:24,-6.88,0.00,ABGESCHLOSSEN
Giro,2026-05-18 12:17:49,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-18 12:31:04,180.00,0.00,ABGESCHLOSSEN
Giro,2026-05-18 14:15:34,-0.10,0.00,ABGESCHLOSSEN
Giro,2026-05-18 19:31:25,-0.99,0.00,ABGESCHLOSSEN
Giro,2026-05-18 20:28:55,-0.87,0.00,ABGESCHLOSSEN
Giro,2026-05-19 05:50:54,-7.90,0.00,ABGESCHLOSSEN
Giro,2026-05-19 05:50:59,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-05-19 09:46:35,-22.88,0.00,ABGESCHLOSSEN
Giro,2026-05-19 12:19:05,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-19 16:12:19,-17.01,0.00,ABGESCHLOSSEN
Giro,2026-05-19 16:12:25,-68.13,0.00,ABGESCHLOSSEN
Giro,2026-05-20 05:45:45,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-05-20 12:23:55,-0.50,0.00,ABGESCHLOSSEN
Giro,2026-05-21 05:45:44,-3.50,0.00,ABGESCHLOSSEN
Giro,2026-05-21 11:24:20,-0.10,0.00,ABGESCHLOSSEN
Giro,2026-05-21 12:31:14,-0.80,0.00,ABGESCHLOSSEN
Giro,2026-05-22 00:52:30,-0.59,0.00,ABGESCHLOSSEN
Giro,2026-05-22 05:39:22,-13.90,0.00,ABGESCHLOSSEN
Giro,2026-05-22 05:39:29,-8.20,0.00,ABGESCHLOSSEN
Giro,2026-05-22 15:13:55,400.00,0.00,ABGESCHLOSSEN
Giro,2026-05-22 15:14:28,-150.00,0.00,ABGESCHLOSSEN
Giro,2026-05-22 16:05:53,-6.41,0.00,ABGESCHLOSSEN
Giro,2026-05-25 13:59:41,500.00,0.00,ABGESCHLOSSEN
Giro,2026-05-25 14:00:07,-500.00,0.00,ABGESCHLOSSEN
Giro,2026-05-26 12:22:31,-0.50,0.00,ABGESCHLOSSEN
Giro,2026-05-26 16:03:14,-0.70,0.00,ABGESCHLOSSEN
Giro,2026-05-27 06:03:18,-3.30,0.00,ABGESCHLOSSEN
Giro,2026-05-27 06:03:27,-3.50,0.00,ABGESCHLOSSEN
Giro,2026-05-27 06:31:32,300.00,0.00,ABGESCHLOSSEN
Giro,2026-05-27 06:31:49,-200.00,0.00,ABGESCHLOSSEN
Giro,2026-05-27 12:18:19,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-27 16:29:08,300.00,0.00,ABGESCHLOSSEN
Giro,2026-05-28 05:56:36,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-05-28 10:59:23,-0.71,0.00,ABGESCHLOSSEN
Giro,2026-05-28 12:17:27,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-28 17:11:20,-12.29,0.00,ABGESCHLOSSEN
Giro,2026-05-28 17:11:56,-0.65,0.00,ABGESCHLOSSEN
Giro,2026-05-29 02:17:35,0.00,18.99,ABGESCHLOSSEN
Giro,2026-05-29 06:04:09,-5.35,0.00,ABGESCHLOSSEN
Giro,2026-05-29 06:04:15,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-05-29 06:37:30,600.00,0.00,ABGESCHLOSSEN
Giro,2026-05-29 12:24:17,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-29 15:49:33,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-05-29 16:13:57,-0.85,0.00,ABGESCHLOSSEN
Giro,2026-05-30 21:46:07,-2.15,0.00,ABGESCHLOSSEN
Giro,2026-05-30 21:46:21,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-05-31 16:46:20,-509.00,0.00,ABGESCHLOSSEN
Giro,2026-06-01 07:17:06,508.00,0.00,ABGESCHLOSSEN
Giro,2026-06-01 09:11:11,-0.20,0.00,ABGESCHLOSSEN
Giro,2026-06-01 09:28:23,-71.50,0.00,ABGESCHLOSSEN
Giro,2026-06-01 09:28:23,-493.00,0.00,ABGESCHLOSSEN
Giro,2026-06-01 12:17:49,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-02 06:22:14,-3.80,0.00,ABGESCHLOSSEN
Giro,2026-06-02 06:23:31,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-06-02 12:33:11,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-03 05:48:21,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-06-03 06:59:35,-0.05,0.00,ABGESCHLOSSEN
Giro,2026-06-03 08:32:08,-0.80,0.00,ABGESCHLOSSEN
Giro,2026-06-03 18:24:55,-0.15,0.00,ABGESCHLOSSEN
Giro,2026-06-03 19:09:31,-200.00,0.00,ABGESCHLOSSEN
Giro,2026-06-03 19:12:46,500.00,0.00,ABGESCHLOSSEN
Giro,2026-06-03 19:13:07,-200.00,0.00,ABGESCHLOSSEN
Giro,2026-06-04 06:39:06,-19.95,0.00,ABGESCHLOSSEN
Giro,2026-06-04 06:39:41,-8.20,0.00,ABGESCHLOSSEN
Giro,2026-06-04 11:36:52,-16.85,0.00,ABGESCHLOSSEN
Giro,2026-06-05 10:33:28,-0.52,0.00,ABGESCHLOSSEN
Giro,2026-06-05 13:19:02,-0.26,0.00,ABGESCHLOSSEN
Giro,2026-06-05 18:37:43,-0.40,0.00,ABGESCHLOSSEN
Giro,2026-06-06 05:39:54,-1.74,0.00,ABGESCHLOSSEN
Giro,2026-06-06 05:39:54,-30.48,0.00,ABGESCHLOSSEN
Giro,2026-06-06 05:40:20,-4.60,0.00,ABGESCHLOSSEN
Giro,2026-06-06 14:02:29,-0.41,0.00,ABGESCHLOSSEN
Giro,2026-06-07 05:43:00,-72.59,0.00,ABGESCHLOSSEN
Giro,2026-06-07 10:51:57,-0.45,0.00,ABGESCHLOSSEN
Giro,2026-06-07 10:58:42,-0.76,0.00,ABGESCHLOSSEN
Giro,2026-06-08 06:02:21,-9.55,0.00,ABGESCHLOSSEN
Giro,2026-06-08 11:28:49,-10.24,0.00,ABGESCHLOSSEN
Giro,2026-06-08 12:11:23,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-09 05:42:52,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-06-09 12:18:52,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-10 05:37:43,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-06-10 12:08:16,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-11 05:52:08,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-06-11 12:10:11,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-12 09:15:54,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-06-12 12:22:18,-0.50,0.00,ABGESCHLOSSEN
Giro,2026-06-13 05:47:46,-3.50,0.00,ABGESCHLOSSEN
Giro,2026-06-13 15:05:41,-0.80,0.00,ABGESCHLOSSEN
Giro,2026-06-14 05:20:27,-31.20,0.00,ABGESCHLOSSEN
Giro,2026-06-15 12:23:52,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-16 05:39:09,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-06-16 12:31:23,-0.10,0.00,ABGESCHLOSSEN
Giro,2026-06-16 19:26:49,-0.10,0.00,ABGESCHLOSSEN
Giro,2026-06-17 03:00:18,-69.90,0.00,ABGESCHLOSSEN
Giro,2026-06-17 05:50:36,-4.90,0.00,ABGESCHLOSSEN
Giro,2026-06-17 12:25:06,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-18 05:44:13,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-06-18 12:23:28,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-18 17:41:34,-0.20,0.00,ABGESCHLOSSEN
Giro,2026-06-18 21:31:12,-0.53,0.00,ABGESCHLOSSEN
Giro,2026-06-19 05:46:23,-19.80,0.00,ABGESCHLOSSEN
Giro,2026-06-19 05:47:07,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-06-19 12:22:41,-0.50,0.00,ABGESCHLOSSEN
Giro,2026-06-19 14:33:34,-17.47,0.00,ABGESCHLOSSEN
Giro,2026-06-20 05:44:57,-3.50,0.00,ABGESCHLOSSEN
Giro,,-63.94,0.00,STORNIERT
Giro,2026-06-21 13:00:04,-0.06,0.00,ABGESCHLOSSEN
Giro,2026-06-22 00:41:06,-0.51,0.00,ABGESCHLOSSEN
Giro,2026-06-22 10:02:41,-6.49,0.00,ABGESCHLOSSEN
Giro,2026-06-22 12:24:12,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-22 13:57:39,-0.23,0.00,ABGESCHLOSSEN
Giro,2026-06-22 15:43:43,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-22 17:13:45,-0.50,0.00,ABGESCHLOSSEN
Giro,2026-06-22 17:18:11,-0.36,0.00,ABGESCHLOSSEN
Giro,2026-06-22 20:44:49,-0.30,0.00,ABGESCHLOSSEN
Giro,2026-06-23 05:58:20,-3.70,0.00,ABGESCHLOSSEN
Giro,2026-06-23 05:58:27,-10.00,0.00,ABGESCHLOSSEN
Giro,2026-06-23 05:59:13,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-06-23 06:01:18,-92.64,0.00,ABGESCHLOSSEN
Giro,2026-06-23 10:14:09,-78.77,0.00,ABGESCHLOSSEN
Giro,2026-06-24 14:04:08,-7.50,0.00,ABGESCHLOSSEN
Giro,2026-06-24 17:59:54,-0.98,0.00,ABGESCHLOSSEN
Giro,2026-06-25 10:24:46,-0.26,0.00,ABGESCHLOSSEN
Giro,2026-06-25 10:26:07,-0.24,0.00,ABGESCHLOSSEN
Giro,2026-06-26 05:56:49,-44.76,0.00,ABGESCHLOSSEN
Giro,2026-06-26 05:56:49,-14.74,0.00,ABGESCHLOSSEN
Giro,2026-06-26 13:52:49,-33.02,0.00,ABGESCHLOSSEN
Giro,2026-06-27 08:36:36,-82.00,0.00,ABGESCHLOSSEN
Giro,2026-06-27 21:29:06,500.00,0.00,ABGESCHLOSSEN
Giro,2026-06-28 12:00:48,-0.38,0.00,ABGESCHLOSSEN
Giro,2026-06-29 02:14:30,0.00,18.99,ABGESCHLOSSEN
Giro,2026-06-29 12:31:07,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-06-29 15:44:23,-12.62,0.00,ABGESCHLOSSEN
Giro,2026-06-30 06:11:27,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-06-30 12:27:44,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-01 05:54:30,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-01 10:13:09,-288.00,0.00,ABGESCHLOSSEN
Giro,2026-07-01 12:09:30,-0.50,0.00,ABGESCHLOSSEN
Giro,2026-07-01 15:35:09,-0.60,0.00,ABGESCHLOSSEN
Giro,2026-07-02 10:53:34,-9.40,0.00,ABGESCHLOSSEN
Giro,2026-07-02 10:55:02,-3.50,0.00,ABGESCHLOSSEN
Giro,2026-07-02 12:17:13,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-02 16:52:24,-0.75,0.00,ABGESCHLOSSEN
Giro,2026-07-03 05:50:20,-6.25,0.00,ABGESCHLOSSEN
Giro,2026-07-03 05:50:54,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-03 12:21:15,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-03 15:03:14,-0.35,0.00,ABGESCHLOSSEN
Giro,2026-07-03 21:56:51,-0.03,0.00,ABGESCHLOSSEN
Giro,2026-07-03 22:07:20,-0.77,0.00,ABGESCHLOSSEN
Giro,2026-07-04 05:59:23,-4.65,0.00,ABGESCHLOSSEN
Giro,2026-07-04 06:00:18,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-04 07:50:56,-0.63,0.00,ABGESCHLOSSEN
Giro,2026-07-04 10:00:24,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-04 14:37:41,-9.23,0.00,ABGESCHLOSSEN
Giro,2026-07-04 14:37:41,-10.97,0.00,ABGESCHLOSSEN
Giro,2026-07-05 05:37:15,-33.00,0.00,ABGESCHLOSSEN
Giro,2026-07-06 05:52:43,-0.37,0.00,ABGESCHLOSSEN
Giro,2026-07-06 12:27:02,-1.00,0.00,ABGESCHLOSSEN
Giro,,-50.81,0.00,STORNIERT
Giro,2026-07-06 12:30:05,-0.19,0.00,ABGESCHLOSSEN
Giro,2026-07-07 06:16:28,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-07 12:32:47,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-08 05:55:58,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-08 12:17:18,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-08 16:38:38,-0.25,0.00,ABGESCHLOSSEN
Giro,2026-07-08 16:41:23,-0.80,0.00,ABGESCHLOSSEN
Giro,2026-07-08 16:48:09,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-08 16:54:34,300.00,0.00,ABGESCHLOSSEN
Giro,2026-07-08 18:03:27,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-08 21:40:47,-0.10,0.00,ABGESCHLOSSEN
Giro,2026-07-09 05:45:42,-2.00,0.00,ABGESCHLOSSEN
Giro,2026-07-09 05:45:45,-22.75,0.00,ABGESCHLOSSEN
Giro,2026-07-09 05:46:25,-10.00,0.00,ABGESCHLOSSEN
Giro,2026-07-09 05:46:29,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-09 05:47:07,-8.20,0.00,ABGESCHLOSSEN
Giro,2026-07-09 08:42:46,-0.28,0.00,ABGESCHLOSSEN
Giro,2026-07-09 09:20:29,-74.90,0.00,ABGESCHLOSSEN
Giro,2026-07-09 17:55:18,-100.00,0.00,ABGESCHLOSSEN
Giro,2026-07-10 03:16:29,-27.72,0.00,ABGESCHLOSSEN
Giro,2026-07-13 12:22:52,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-13 15:10:12,-0.55,0.00,ABGESCHLOSSEN
Giro,2026-07-14 05:58:53,-20.45,0.00,ABGESCHLOSSEN
Giro,2026-07-14 05:59:29,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-14 12:23:58,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-15 05:57:18,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-15 12:08:52,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-16 05:37:15,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-16 09:00:30,-0.57,0.00,ABGESCHLOSSEN
Giro,2026-07-16 12:22:05,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-17 05:52:10,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-17 11:36:24,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-17 14:41:39,-0.25,0.00,ABGESCHLOSSEN
Giro,2026-07-18 05:57:24,-13.75,0.00,ABGESCHLOSSEN
Giro,2026-07-18 05:57:44,-6.00,0.00,ABGESCHLOSSEN
Giro,2026-07-18 11:07:39,-23.43,0.00,ABGESCHLOSSEN
Giro,2026-07-20 11:47:14,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-21 06:09:34,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-21 16:12:22,-0.05,0.00,ABGESCHLOSSEN
Giro,2026-07-21 18:02:22,200.00,0.00,ABGESCHLOSSEN
Giro,2026-07-22 05:46:31,-8.95,0.00,ABGESCHLOSSEN
Giro,2026-07-22 08:44:05,-50.00,0.00,ABGESCHLOSSEN
Giro,2026-07-22 11:57:45,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-23 07:31:54,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-23 11:57:22,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-24 06:01:57,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-24 11:48:49,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-07-24 14:25:04,-0.45,0.00,ABGESCHLOSSEN
Giro,2026-07-25 06:42:57,-5.00,0.00,ABGESCHLOSSEN
Giro,2026-07-25 20:57:07,-3.55,0.00,ABGESCHLOSSEN
Giro,2026-07-26 18:50:39,-0.70,0.00,ABGESCHLOSSEN
Giro,2026-07-27 05:50:40,-29.30,0.00,ABGESCHLOSSEN
Giro,2026-07-28 12:01:05,-0.23,0.00,ABGESCHLOSSEN
Giro,2026-07-29 02:28:20,0.00,20.99,ABGESCHLOSSEN
Giro,2026-07-29 12:05:27,-41.09,0.00,ABGESCHLOSSEN
Giro,2026-07-29 14:07:25,-12.77,0.00,ABGESCHLOSSEN
Giro,2026-07-29 18:44:44,500.00,0.00,ABGESCHLOSSEN
Giro,2026-07-30 09:56:27,-25.57,0.00,ABGESCHLOSSEN
Giro,2026-07-30 14:57:11,-0.51,0.00,ABGESCHLOSSEN
Giro,2026-07-30 15:27:59,-0.96,0.00,ABGESCHLOSSEN
Giro,2026-07-31 03:12:39,-28.49,0.00,ABGESCHLOSSEN
Giro,2026-07-31 10:13:40,-197.04,0.00,ABGESCHLOSSEN
Giro,2026-07-31 15:49:42,-0.94,0.00,ABGESCHLOSSEN
Giro,2026-08-01 05:28:43,-29.06,0.00,ABGESCHLOSSEN
Giro,2026-08-01 12:32:07,-0.01,0.00,ABGESCHLOSSEN
Giro,2026-08-01 12:36:08,300.00,0.00,ABGESCHLOSSEN
Giro,2026-08-01 12:36:41,-300.00,0.00,ABGESCHLOSSEN
Giro,2026-08-02 09:21:34,-14.99,0.00,ABGESCHLOSSEN
Giro,2026-08-05 15:00:39,-1.00,0.00,ABGESCHLOSSEN
Giro,2026-08-05 16:08:01,-0.87,0.00,ABGESCHLOSSEN
Giro,2026-08-05 16:12:53,-130.00,0.00,ABGESCHLOSSEN
Giro,2026-08-06 07:39:38,-2.13,0.00,ABGESCHLOSSEN
Giro,2026-08-07 06:01:40,-11.00,0.00,ABGESCHLOSSEN
Giro,2026-08-08 13:55:31,-0.76,0.00,ABGESCHLOSSEN
Giro,2026-08-09 09:08:49,-68.24,0.00,ABGESCHLOSSEN
Giro,2026-08-11 15:06:56,300.00,0.00,ABGESCHLOSSEN
Giro,2026-08-11 15:09:15,-0.89,0.00,ABGESCHLOSSEN
Giro,2026-08-11 15:14:08,-0.14,0.00,ABGESCHLOSSEN
Giro,2026-08-12 06:08:26,-11.11,0.00,ABGESCHLOSSEN
Giro,2026-08-12 09:44:51,-1.86,0.00,ABGESCHLOSSEN
Giro,2026-08-12 09:47:17,-0.91,0.00,ABGESCHLOSSEN
Giro,2026-08-12 11:27:41,-150.00,0.00,ABGESCHLOSSEN
Giro,2026-08-13 09:31:24,-11.09,0.00,ABGESCHLOSSEN
Giro,2026-08-15 09:00:24,-0.35,0.00,ABGESCHLOSSEN
Giro,2026-08-16 18:58:39,-47.16,0.00,ABGESCHLOSSEN
Giro,2026-08-17 12:28:01,-0.94,0.00,ABGESCHLOSSEN
Giro,2026-08-18 03:17:33,-23.65,0.00,ABGESCHLOSSEN
Giro,2026-08-18 09:59:09,-4.06,0.00,ABGESCHLOSSEN
Erspartes,2026-01-09 08:35:23,0.09,0.00,ABGESCHLOSSEN
Erspartes,2026-01-22 23:18:19,0.62,0.00,ABGESCHLOSSEN
Erspartes,2026-02-16 11:44:57,0.13,0.00,ABGESCHLOSSEN
Erspartes,2026-02-22 00:53:01,0.60,0.00,ABGESCHLOSSEN
Erspartes,2026-02-25 22:39:06,250.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-01 22:29:19,0.50,0.00,ABGESCHLOSSEN
Erspartes,2026-03-02 12:23:17,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-03 12:10:14,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-03 13:46:18,0.10,0.00,ABGESCHLOSSEN
Erspartes,2026-03-04 12:32:28,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-05 12:12:22,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-05 17:51:07,0.65,0.00,ABGESCHLOSSEN
Erspartes,2026-03-06 12:26:46,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-08 12:35:06,0.51,0.00,ABGESCHLOSSEN
Erspartes,2026-03-09 12:33:54,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-10 12:19:07,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-13 09:49:50,-250.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-13 12:09:24,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-14 06:59:13,0.25,0.00,ABGESCHLOSSEN
Erspartes,2026-03-16 20:10:07,-14.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-18 21:06:13,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-22 00:53:05,0.61,0.00,ABGESCHLOSSEN
Erspartes,2026-03-24 09:07:28,0.50,0.00,ABGESCHLOSSEN
Erspartes,2026-03-24 11:58:48,0.20,0.00,ABGESCHLOSSEN
Erspartes,2026-03-24 12:01:21,0.40,0.00,ABGESCHLOSSEN
Erspartes,2026-03-25 12:09:47,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-26 12:29:53,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-26 18:08:29,0.78,0.00,ABGESCHLOSSEN
Erspartes,2026-03-26 18:09:19,0.18,0.00,ABGESCHLOSSEN
Erspartes,2026-03-26 18:38:13,0.64,0.00,ABGESCHLOSSEN
Erspartes,2026-03-26 18:51:15,0.14,0.00,ABGESCHLOSSEN
Erspartes,2026-03-26 19:56:25,0.49,0.00,ABGESCHLOSSEN
Erspartes,2026-03-27 11:50:22,0.40,0.00,ABGESCHLOSSEN
Erspartes,2026-03-27 12:59:06,0.90,0.00,ABGESCHLOSSEN
Erspartes,2026-03-29 15:23:03,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-31 08:53:49,0.60,0.00,ABGESCHLOSSEN
Erspartes,2026-03-31 08:54:52,0.60,0.00,ABGESCHLOSSEN
Erspartes,2026-03-31 08:55:56,0.60,0.00,ABGESCHLOSSEN
Erspartes,2026-03-31 08:56:55,0.60,0.00,ABGESCHLOSSEN
Erspartes,2026-03-31 12:24:31,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-03-31 16:32:12,0.55,0.00,ABGESCHLOSSEN
Erspartes,2026-04-01 12:10:56,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-02 17:17:20,0.90,0.00,ABGESCHLOSSEN
Erspartes,2026-04-07 12:13:20,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-08 12:13:34,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-09 12:20:29,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-10 12:25:07,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-10 18:06:55,0.50,0.00,ABGESCHLOSSEN
Erspartes,2026-04-12 20:14:08,0.84,0.00,ABGESCHLOSSEN
Erspartes,2026-04-13 12:26:16,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-13 18:11:28,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-14 12:27:49,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-14 13:38:24,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-14 17:51:29,0.45,0.00,ABGESCHLOSSEN
Erspartes,2026-04-15 12:11:01,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-16 09:18:01,0.80,0.00,ABGESCHLOSSEN
Erspartes,2026-04-16 10:35:07,0.80,0.00,ABGESCHLOSSEN
Erspartes,2026-04-16 12:07:12,0.50,0.00,ABGESCHLOSSEN
Erspartes,2026-04-16 12:52:23,0.05,0.00,ABGESCHLOSSEN
Erspartes,2026-04-17 11:23:52,0.26,0.00,ABGESCHLOSSEN
Erspartes,2026-04-17 19:17:03,0.91,0.00,ABGESCHLOSSEN
Erspartes,2026-04-18 16:55:39,0.33,0.00,ABGESCHLOSSEN
Erspartes,2026-04-18 19:33:01,0.05,0.00,ABGESCHLOSSEN
Erspartes,2026-04-19 10:54:04,0.70,0.00,ABGESCHLOSSEN
Erspartes,2026-04-19 20:24:50,0.04,0.00,ABGESCHLOSSEN
Erspartes,2026-04-20 12:19:07,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-21 12:28:05,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-22 01:00:05,0.57,0.00,ABGESCHLOSSEN
Erspartes,2026-04-22 12:07:52,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-22 22:25:17,0.33,0.00,ABGESCHLOSSEN
Erspartes,2026-04-23 12:22:59,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-23 16:57:05,0.30,0.00,ABGESCHLOSSEN
Erspartes,2026-04-24 12:23:32,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-24 14:57:44,0.25,0.00,ABGESCHLOSSEN
Erspartes,2026-04-24 15:02:08,0.40,0.00,ABGESCHLOSSEN
Erspartes,2026-04-24 15:04:18,0.20,0.00,ABGESCHLOSSEN
Erspartes,2026-04-25 03:32:21,0.92,0.00,ABGESCHLOSSEN
Erspartes,2026-04-25 12:49:00,0.25,0.00,ABGESCHLOSSEN
Erspartes,2026-04-25 12:59:57,0.88,0.00,ABGESCHLOSSEN
Erspartes,2026-04-27 12:22:42,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-27 21:18:04,0.91,0.00,ABGESCHLOSSEN
Erspartes,2026-04-28 12:25:23,0.50,0.00,ABGESCHLOSSEN
Erspartes,2026-04-29 12:12:48,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-04-29 18:07:51,0.19,0.00,ABGESCHLOSSEN
Erspartes,2026-04-29 18:55:39,0.12,0.00,ABGESCHLOSSEN
Erspartes,2026-04-29 20:08:53,0.28,0.00,ABGESCHLOSSEN
Erspartes,2026-04-30 07:45:22,0.80,0.00,ABGESCHLOSSEN
Erspartes,2026-04-30 11:23:32,0.30,0.00,ABGESCHLOSSEN
Erspartes,2026-04-30 11:26:31,0.30,0.00,ABGESCHLOSSEN
Erspartes,2026-04-30 17:44:44,0.19,0.00,ABGESCHLOSSEN
Erspartes,2026-05-04 04:28:56,0.77,0.00,ABGESCHLOSSEN
Erspartes,2026-05-04 18:45:45,0.10,0.00,ABGESCHLOSSEN
Erspartes,2026-05-05 12:11:03,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-05 14:51:21,0.35,0.00,ABGESCHLOSSEN
Erspartes,2026-05-06 12:11:47,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-07 12:24:35,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-08 12:56:33,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-09 14:05:03,0.60,0.00,ABGESCHLOSSEN
Erspartes,2026-05-11 12:13:29,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-12 12:11:58,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-13 11:48:16,0.95,0.00,ABGESCHLOSSEN
Erspartes,2026-05-13 12:59:20,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-13 19:11:45,0.17,0.00,ABGESCHLOSSEN
Erspartes,2026-05-13 20:38:44,0.66,0.00,ABGESCHLOSSEN
Erspartes,2026-05-14 15:00:51,0.85,0.00,ABGESCHLOSSEN
Erspartes,2026-05-14 16:40:47,0.99,0.00,ABGESCHLOSSEN
Erspartes,2026-05-14 16:56:24,0.20,0.00,ABGESCHLOSSEN
Erspartes,2026-05-15 13:46:19,0.45,0.00,ABGESCHLOSSEN
Erspartes,2026-05-15 14:38:44,0.93,0.00,ABGESCHLOSSEN
Erspartes,2026-05-15 15:37:15,0.34,0.00,ABGESCHLOSSEN
Erspartes,2026-05-15 17:21:07,0.21,0.00,ABGESCHLOSSEN
Erspartes,2026-05-15 17:58:24,0.33,0.00,ABGESCHLOSSEN
Erspartes,2026-05-16 12:48:49,0.12,0.00,ABGESCHLOSSEN
Erspartes,2026-05-16 12:49:41,0.67,0.00,ABGESCHLOSSEN
Erspartes,2026-05-16 14:06:38,0.08,0.00,ABGESCHLOSSEN
Erspartes,2026-05-16 14:36:40,0.96,0.00,ABGESCHLOSSEN
Erspartes,2026-05-16 15:22:19,0.52,0.00,ABGESCHLOSSEN
Erspartes,2026-05-16 21:22:56,0.76,0.00,ABGESCHLOSSEN
Erspartes,2026-05-17 09:00:32,0.12,0.00,ABGESCHLOSSEN
Erspartes,2026-05-18 12:17:49,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-18 14:15:34,0.10,0.00,ABGESCHLOSSEN
Erspartes,2026-05-18 19:31:25,0.99,0.00,ABGESCHLOSSEN
Erspartes,2026-05-18 20:28:55,0.87,0.00,ABGESCHLOSSEN
Erspartes,2026-05-19 12:19:05,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-20 12:23:55,0.50,0.00,ABGESCHLOSSEN
Erspartes,2026-05-21 11:24:20,0.10,0.00,ABGESCHLOSSEN
Erspartes,2026-05-21 12:31:14,0.80,0.00,ABGESCHLOSSEN
Erspartes,2026-05-22 00:52:30,0.59,0.00,ABGESCHLOSSEN
Erspartes,2026-05-22 15:15:17,0.89,0.00,ABGESCHLOSSEN
Erspartes,2026-05-22 15:36:08,0.46,0.00,ABGESCHLOSSEN
Erspartes,2026-05-23 19:12:51,0.03,0.00,ABGESCHLOSSEN
Erspartes,2026-05-23 19:21:30,0.77,0.00,ABGESCHLOSSEN
Erspartes,2026-05-25 09:28:38,0.56,0.00,ABGESCHLOSSEN
Erspartes,2026-05-25 10:57:01,0.49,0.00,ABGESCHLOSSEN
Erspartes,2026-05-26 12:22:31,0.50,0.00,ABGESCHLOSSEN
Erspartes,2026-05-26 16:03:14,0.70,0.00,ABGESCHLOSSEN
Erspartes,2026-05-27 12:18:19,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-28 10:59:23,0.71,0.00,ABGESCHLOSSEN
Erspartes,2026-05-28 12:17:27,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-28 17:11:56,0.65,0.00,ABGESCHLOSSEN
Erspartes,2026-05-29 12:24:17,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-29 15:49:33,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-05-29 16:13:57,0.85,0.00,ABGESCHLOSSEN
Erspartes,2026-06-01 09:11:11,0.20,0.00,ABGESCHLOSSEN
Erspartes,2026-06-01 12:17:49,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-02 12:33:11,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-03 06:59:35,0.05,0.00,ABGESCHLOSSEN
Erspartes,2026-06-03 08:32:08,0.80,0.00,ABGESCHLOSSEN
Erspartes,2026-06-03 18:24:55,0.15,0.00,ABGESCHLOSSEN
Erspartes,2026-06-05 10:33:28,0.52,0.00,ABGESCHLOSSEN
Erspartes,2026-06-05 13:19:02,0.26,0.00,ABGESCHLOSSEN
Erspartes,2026-06-05 18:37:43,0.40,0.00,ABGESCHLOSSEN
Erspartes,2026-06-06 14:02:29,0.41,0.00,ABGESCHLOSSEN
Erspartes,2026-06-07 10:51:57,0.45,0.00,ABGESCHLOSSEN
Erspartes,2026-06-07 10:58:42,0.76,0.00,ABGESCHLOSSEN
Erspartes,2026-06-08 12:11:23,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-09 12:18:52,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-10 12:08:16,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-11 12:10:11,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-12 12:22:18,0.50,0.00,ABGESCHLOSSEN
Erspartes,2026-06-13 15:05:41,0.80,0.00,ABGESCHLOSSEN
Erspartes,2026-06-15 12:23:52,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-16 09:00:33,0.79,0.00,ABGESCHLOSSEN
Erspartes,2026-06-16 12:31:23,0.10,0.00,ABGESCHLOSSEN
Erspartes,2026-06-16 19:26:49,0.10,0.00,ABGESCHLOSSEN
Erspartes,2026-06-17 12:25:06,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-18 12:23:28,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-18 17:41:34,0.20,0.00,ABGESCHLOSSEN
Erspartes,2026-06-18 21:31:12,0.53,0.00,ABGESCHLOSSEN
Erspartes,2026-06-19 12:22:41,0.50,0.00,ABGESCHLOSSEN
Erspartes,2026-06-21 13:00:04,0.06,0.00,ABGESCHLOSSEN
Erspartes,2026-06-22 00:41:06,0.51,0.00,ABGESCHLOSSEN
Erspartes,2026-06-22 12:24:12,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-22 13:57:39,0.23,0.00,ABGESCHLOSSEN
Erspartes,2026-06-22 15:43:43,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-22 17:13:45,0.50,0.00,ABGESCHLOSSEN
Erspartes,2026-06-22 17:18:11,0.36,0.00,ABGESCHLOSSEN
Erspartes,2026-06-22 20:44:49,0.30,0.00,ABGESCHLOSSEN
Erspartes,2026-06-24 17:59:54,0.98,0.00,ABGESCHLOSSEN
Erspartes,2026-06-25 10:24:46,0.26,0.00,ABGESCHLOSSEN
Erspartes,2026-06-25 10:26:07,0.24,0.00,ABGESCHLOSSEN
Erspartes,2026-06-27 09:50:05,0.43,0.00,ABGESCHLOSSEN
Erspartes,2026-06-28 10:23:51,0.82,0.00,ABGESCHLOSSEN
Erspartes,2026-06-28 10:26:05,0.82,0.00,ABGESCHLOSSEN
Erspartes,2026-06-28 10:49:14,0.18,0.00,ABGESCHLOSSEN
Erspartes,2026-06-28 12:00:48,0.38,0.00,ABGESCHLOSSEN
Erspartes,2026-06-29 12:31:07,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-06-30 12:27:44,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-01 11:36:08,0.47,0.00,ABGESCHLOSSEN
Erspartes,2026-07-01 12:09:30,0.50,0.00,ABGESCHLOSSEN
Erspartes,2026-07-01 15:35:09,0.60,0.00,ABGESCHLOSSEN
Erspartes,2026-07-02 12:17:13,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-02 16:52:24,0.75,0.00,ABGESCHLOSSEN
Erspartes,2026-07-03 12:21:15,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-03 15:03:14,0.35,0.00,ABGESCHLOSSEN
Erspartes,2026-07-03 21:56:51,0.03,0.00,ABGESCHLOSSEN
Erspartes,2026-07-03 22:07:20,0.77,0.00,ABGESCHLOSSEN
Erspartes,2026-07-04 07:50:56,0.63,0.00,ABGESCHLOSSEN
Erspartes,2026-07-04 10:00:24,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-06 12:27:02,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-06 12:30:05,0.19,0.00,ABGESCHLOSSEN
Erspartes,2026-07-07 12:32:47,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-08 12:17:18,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-08 16:38:38,0.25,0.00,ABGESCHLOSSEN
Erspartes,2026-07-08 16:41:23,0.80,0.00,ABGESCHLOSSEN
Erspartes,2026-07-08 16:48:09,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-08 18:03:27,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-08 21:40:47,0.10,0.00,ABGESCHLOSSEN
Erspartes,2026-07-09 08:42:46,0.28,0.00,ABGESCHLOSSEN
Erspartes,2026-07-09 12:36:44,0.73,0.00,ABGESCHLOSSEN
Erspartes,2026-07-09 16:35:27,0.09,0.00,ABGESCHLOSSEN
Erspartes,2026-07-10 15:55:49,0.88,0.00,ABGESCHLOSSEN
Erspartes,2026-07-12 12:08:26,0.92,0.00,ABGESCHLOSSEN
Erspartes,2026-07-13 12:22:52,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-13 15:10:12,0.55,0.00,ABGESCHLOSSEN
Erspartes,2026-07-14 12:23:58,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-15 12:08:52,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-16 09:00:30,0.57,0.00,ABGESCHLOSSEN
Erspartes,2026-07-16 12:22:05,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-17 11:36:24,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-17 14:41:39,0.25,0.00,ABGESCHLOSSEN
Erspartes,2026-07-20 11:47:14,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-21 16:12:22,0.05,0.00,ABGESCHLOSSEN
Erspartes,2026-07-22 11:57:45,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-23 11:57:22,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-24 11:48:49,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-07-24 14:25:04,0.45,0.00,ABGESCHLOSSEN
Erspartes,2026-07-26 18:50:39,0.70,0.00,ABGESCHLOSSEN
Erspartes,2026-07-28 12:01:05,0.23,0.00,ABGESCHLOSSEN
Erspartes,2026-07-30 14:57:11,0.51,0.00,ABGESCHLOSSEN
Erspartes,2026-07-30 15:14:32,0.46,0.00,ABGESCHLOSSEN
Erspartes,2026-07-30 15:27:59,0.96,0.00,ABGESCHLOSSEN
Erspartes,2026-07-31 15:49:42,0.94,0.00,ABGESCHLOSSEN
Erspartes,2026-08-01 12:32:07,0.01,0.00,ABGESCHLOSSEN
Erspartes,2026-08-05 15:00:39,1.00,0.00,ABGESCHLOSSEN
Erspartes,2026-08-05 16:08:01,0.87,0.00,ABGESCHLOSSEN
Erspartes,2026-08-05 16:21:32,0.28,0.00,ABGESCHLOSSEN
Erspartes,2026-08-05 17:34:07,0.60,0.00,ABGESCHLOSSEN
Erspartes,2026-08-05 18:16:55,0.33,0.00,ABGESCHLOSSEN
Erspartes,2026-08-05 18:41:57,0.92,0.00,ABGESCHLOSSEN
Erspartes,2026-08-08 13:12:53,0.13,0.00,ABGESCHLOSSEN
Erspartes,2026-08-08 13:55:31,0.76,0.00,ABGESCHLOSSEN
Erspartes,2026-08-08 22:11:09,-142.00,0.00,ABGESCHLOSSEN
Erspartes,2026-08-11 15:09:15,0.89,0.00,ABGESCHLOSSEN
Erspartes,2026-08-11 15:14:08,0.14,0.00,ABGESCHLOSSEN
Erspartes,2026-08-12 09:47:17,0.91,0.00,ABGESCHLOSSEN
Erspartes,2026-08-12 11:30:15,0.30,0.00,ABGESCHLOSSEN
Erspartes,2026-08-12 16:38:55,0.09,0.00,ABGESCHLOSSEN
Erspartes,2026-08-15 09:00:24,0.35,0.00,ABGESCHLOSSEN
Erspartes,2026-08-15 15:39:11,0.93,0.00,ABGESCHLOSSEN
Erspartes,2026-08-15 16:06:38,0.16,0.00,ABGESCHLOSSEN
Erspartes,2026-08-15 16:57:03,0.71,0.00,ABGESCHLOSSEN
Erspartes,2026-08-15 21:51:54,0.46,0.00,ABGESCHLOSSEN
Erspartes,2026-08-17 12:28:01,0.94,0.00,ABGESCHLOSSEN
Erspartes,2026-08-17 14:39:39,0.55,0.00,ABGESCHLOSSEN
1 Produkt Datum des Abschlusses Betrag Gebühr Status
2 Giro 2026-01-06 23:05:01 100.00 0.00 ABGESCHLOSSEN
3 Giro 2026-01-06 23:08:36 150.00 0.00 ABGESCHLOSSEN
4 Giro 2026-01-06 23:08:58 -100.00 0.00 ABGESCHLOSSEN
5 Giro 2026-01-06 23:09:35 -31.90 0.00 ABGESCHLOSSEN
6 Giro 2026-01-06 23:10:22 -118.00 0.00 ABGESCHLOSSEN
7 Giro 2026-01-08 15:53:25 150.00 0.00 ABGESCHLOSSEN
8 Giro 2026-01-09 08:34:12 -50.00 0.00 ABGESCHLOSSEN
9 Giro 2026-01-11 03:17:01 0.00 10.99 ABGESCHLOSSEN
10 Giro 2026-01-22 23:18:19 -0.62 0.00 ABGESCHLOSSEN
11 Giro 2026-01-23 21:51:34 200.00 0.00 ABGESCHLOSSEN
12 Giro 2026-01-23 21:52:04 -214.00 0.00 ABGESCHLOSSEN
13 Giro 2026-01-24 12:13:44 -74.38 0.00 ABGESCHLOSSEN
14 Giro 2026-01-27 08:40:42 200.00 0.00 ABGESCHLOSSEN
15 Giro 2026-01-27 08:40:59 -200.00 0.00 ABGESCHLOSSEN
16 Giro 2026-01-29 07:55:31 100.00 0.00 ABGESCHLOSSEN
17 Giro 2026-01-29 10:21:45 -7.90 0.00 ABGESCHLOSSEN
18 Giro 2026-01-31 22:52:29 -50.00 0.00 ABGESCHLOSSEN
19 Giro 2026-02-01 14:37:59 -40.00 0.00 ABGESCHLOSSEN
20 Giro 2026-02-05 06:52:44 200.00 0.00 ABGESCHLOSSEN
21 Giro 2026-02-05 06:53:05 -200.00 0.00 ABGESCHLOSSEN
22 Giro 2026-02-11 01:53:11 0.00 10.99 ABGESCHLOSSEN
23 Giro 2026-02-11 02:00:14 0.48 0.00 ABGESCHLOSSEN
24 Giro 2026-02-15 10:12:37 100.00 0.00 ABGESCHLOSSEN
25 Giro 2026-02-16 11:44:57 -0.13 0.00 ABGESCHLOSSEN
26 Giro 2026-02-16 21:01:29 200.00 0.00 ABGESCHLOSSEN
27 Giro 2026-02-16 21:01:53 -137.06 0.00 ABGESCHLOSSEN
28 Giro 2026-02-17 14:49:34 -6.87 0.00 ABGESCHLOSSEN
29 Giro 2026-02-20 19:54:32 -90.00 0.00 ABGESCHLOSSEN
30 Giro 2026-02-22 00:53:01 -0.60 0.00 ABGESCHLOSSEN
31 Giro 2026-02-23 14:30:43 -6.40 0.00 ABGESCHLOSSEN
32 Giro 2026-02-25 22:38:31 600.00 0.00 ABGESCHLOSSEN
33 Giro 2026-02-25 22:39:06 -250.00 0.00 ABGESCHLOSSEN
34 Giro 2026-02-25 22:39:39 -400.00 0.00 ABGESCHLOSSEN
35 Giro 2026-02-27 17:30:31 1000.00 0.00 ABGESCHLOSSEN
36 Giro 2026-02-28 20:53:45 -300.00 0.00 ABGESCHLOSSEN
37 Giro 2026-02-28 20:57:27 -80.00 0.00 ABGESCHLOSSEN
38 Giro 2026-03-01 22:29:19 -0.50 0.00 ABGESCHLOSSEN
39 Giro 2026-03-02 02:59:43 -311.65 0.00 ABGESCHLOSSEN
40 Giro 2026-03-02 12:23:17 -1.00 0.00 ABGESCHLOSSEN
41 Giro 2026-03-02 14:32:29 -36.50 0.00 ABGESCHLOSSEN
42 Giro 2026-03-03 12:10:14 -1.00 0.00 ABGESCHLOSSEN
43 Giro 2026-03-03 13:46:18 -0.10 0.00 ABGESCHLOSSEN
44 Giro 2026-03-03 15:41:39 -5.00 0.00 ABGESCHLOSSEN
45 Giro 2026-03-04 12:32:28 -1.00 0.00 ABGESCHLOSSEN
46 Giro 2026-03-04 14:07:54 -44.90 0.00 ABGESCHLOSSEN
47 Giro 2026-03-04 14:15:50 -5.00 0.00 ABGESCHLOSSEN
48 Giro 2026-03-05 12:12:22 -1.00 0.00 ABGESCHLOSSEN
49 Giro 2026-03-05 13:44:47 -35.90 0.00 ABGESCHLOSSEN
50 Giro 2026-03-05 15:29:35 -5.00 0.00 ABGESCHLOSSEN
51 Giro 2026-03-05 17:51:07 -0.65 0.00 ABGESCHLOSSEN
52 Giro 2026-03-06 12:26:46 -1.00 0.00 ABGESCHLOSSEN
53 Giro 2026-03-06 16:34:22 -5.00 0.00 ABGESCHLOSSEN
54 Giro 2026-03-06 16:35:55 -15.35 0.00 ABGESCHLOSSEN
55 Giro 2026-03-07 17:02:02 -5.00 0.00 ABGESCHLOSSEN
56 Giro 2026-03-08 12:35:06 -0.51 0.00 ABGESCHLOSSEN
57 Giro 2026-03-09 12:33:54 -1.00 0.00 ABGESCHLOSSEN
58 Giro 2026-03-10 12:19:07 -1.00 0.00 ABGESCHLOSSEN
59 Giro 2026-03-10 14:39:13 -64.49 0.00 ABGESCHLOSSEN
60 Giro 2026-03-10 14:42:36 -5.00 0.00 ABGESCHLOSSEN
61 Giro 2026-03-11 03:32:54 0.00 10.99 ABGESCHLOSSEN
62 Giro 2026-03-11 14:39:14 -5.00 0.00 ABGESCHLOSSEN
63 Giro 2026-03-13 09:49:50 250.00 0.00 ABGESCHLOSSEN
64 Giro 2026-03-13 12:09:24 -1.00 0.00 ABGESCHLOSSEN
65 Giro 2026-03-13 14:31:31 44.98 0.00 ABGESCHLOSSEN
66 Giro 2026-03-14 06:59:13 -0.25 0.00 ABGESCHLOSSEN
67 Giro 2026-03-14 16:24:40 -5.00 0.00 ABGESCHLOSSEN
68 Giro 2026-03-15 15:03:17 -13.75 0.00 ABGESCHLOSSEN
69 Giro 2026-03-16 02:38:04 -332.15 0.00 ABGESCHLOSSEN
70 Giro 2026-03-16 14:07:07 300.00 0.00 ABGESCHLOSSEN
71 Giro 2026-03-16 14:08:20 -150.00 0.00 ABGESCHLOSSEN
72 Giro 2026-03-16 20:10:07 14.00 0.00 ABGESCHLOSSEN
73 Giro 2026-03-16 20:12:14 121.42 1.00 ABGESCHLOSSEN
74 Giro 2026-03-18 21:06:13 -1.00 0.00 ABGESCHLOSSEN
75 Giro 2026-03-20 15:14:47 -7.00 0.00 ABGESCHLOSSEN
76 Giro 2026-03-22 00:53:05 -0.61 0.00 ABGESCHLOSSEN
77 Giro 2026-03-23 09:52:13 -6.39 0.00 ABGESCHLOSSEN
78 Giro 2026-03-23 11:37:09 -50.00 0.00 ABGESCHLOSSEN
79 Giro 2026-03-24 09:07:28 -0.50 0.00 ABGESCHLOSSEN
80 Giro 2026-03-24 11:58:48 -0.20 0.00 ABGESCHLOSSEN
81 Giro 2026-03-24 12:01:21 -0.40 0.00 ABGESCHLOSSEN
82 Giro 2026-03-25 12:09:47 -1.00 0.00 ABGESCHLOSSEN
83 Giro 2026-03-25 14:22:16 -58.80 0.00 ABGESCHLOSSEN
84 Giro 2026-03-25 14:43:44 -4.50 0.00 ABGESCHLOSSEN
85 Giro 2026-03-25 15:13:19 -7.60 0.00 ABGESCHLOSSEN
86 Giro 2026-03-26 06:13:52 -39.67 0.00 ABGESCHLOSSEN
87 Giro 2026-03-26 12:29:53 -1.00 0.00 ABGESCHLOSSEN
88 Giro 2026-03-26 13:25:23 -5.00 0.00 ABGESCHLOSSEN
89 Giro 2026-03-26 18:08:29 -0.78 0.00 ABGESCHLOSSEN
90 Giro 2026-03-26 18:09:19 -0.18 0.00 ABGESCHLOSSEN
91 Giro 2026-03-26 18:38:13 -0.64 0.00 ABGESCHLOSSEN
92 Giro 2026-03-26 18:51:15 -0.14 0.00 ABGESCHLOSSEN
93 Giro 2026-03-26 19:55:19 500.00 0.00 ABGESCHLOSSEN
94 Giro 2026-03-26 19:56:25 -0.49 0.00 ABGESCHLOSSEN
95 Giro 2026-03-27 10:51:30 -19.51 0.00 ABGESCHLOSSEN
96 Giro 2026-03-27 11:50:22 -0.40 0.00 ABGESCHLOSSEN
97 Giro 2026-03-27 12:59:06 -0.90 0.00 ABGESCHLOSSEN
98 Giro 2026-03-27 14:16:41 -17.22 0.00 ABGESCHLOSSEN
99 Giro 2026-03-27 14:22:52 -25.82 0.00 ABGESCHLOSSEN
100 Giro 2026-03-27 14:52:27 -13.36 0.00 ABGESCHLOSSEN
101 Giro 2026-03-27 14:52:49 -8.86 0.00 ABGESCHLOSSEN
102 Giro 2026-03-27 15:06:10 -5.00 0.00 ABGESCHLOSSEN
103 Giro 2026-03-28 16:57:51 -18.60 0.00 ABGESCHLOSSEN
104 Giro 2026-03-28 16:58:18 -11.10 0.00 ABGESCHLOSSEN
105 Giro 2026-03-29 15:21:56 1600.00 0.00 ABGESCHLOSSEN
106 Giro 2026-03-29 15:23:03 -1.00 0.00 ABGESCHLOSSEN
107 Giro 2026-03-29 23:42:39 0.00 -10.99 ABGESCHLOSSEN
108 Giro 2026-03-29 23:42:39 0.00 18.99 ABGESCHLOSSEN
109 Giro 2026-03-31 08:53:49 -0.60 0.00 ABGESCHLOSSEN
110 Giro 2026-03-31 08:54:52 -0.60 0.00 ABGESCHLOSSEN
111 Giro 2026-03-31 08:55:56 -0.60 0.00 ABGESCHLOSSEN
112 Giro 2026-03-31 08:56:55 -0.60 0.00 ABGESCHLOSSEN
113 Giro 2026-03-31 12:24:31 -1.00 0.00 ABGESCHLOSSEN
114 Giro 2026-03-31 15:56:58 -2.40 0.00 ABGESCHLOSSEN
115 Giro 2026-03-31 15:56:58 -2.40 0.00 ABGESCHLOSSEN
116 Giro 2026-03-31 15:56:58 -2.40 0.00 ABGESCHLOSSEN
117 Giro 2026-03-31 16:32:12 -0.55 0.00 ABGESCHLOSSEN
118 Giro 2026-03-31 16:37:54 -1999.00 0.00 ABGESCHLOSSEN
119 Giro 2026-03-31 17:30:14 -2.40 0.00 ABGESCHLOSSEN
120 Giro 2026-04-01 12:10:56 -1.00 0.00 ABGESCHLOSSEN
121 Giro 2026-04-01 16:47:24 -5.00 0.00 ABGESCHLOSSEN
122 Giro 2026-04-01 16:50:25 -9.45 0.00 ABGESCHLOSSEN
123 Giro 2026-04-02 08:13:54 808.00 0.00 ABGESCHLOSSEN
124 Giro 2026-04-02 08:14:15 -400.00 0.00 ABGESCHLOSSEN
125 Giro 2026-04-02 14:28:21 -5.00 0.00 ABGESCHLOSSEN
126 Giro 2026-04-02 17:17:20 -0.90 0.00 ABGESCHLOSSEN
127 Giro 2026-04-02 18:39:01 -265.98 0.00 ABGESCHLOSSEN
128 Giro 2026-04-03 14:21:13 -13.10 0.00 ABGESCHLOSSEN
129 Giro 2026-04-05 13:33:31 -30.00 0.00 ABGESCHLOSSEN
130 Giro 2026-04-07 08:44:07 -100.00 0.00 ABGESCHLOSSEN
131 Giro 2026-04-07 11:01:40 500.00 0.00 ABGESCHLOSSEN
132 Giro 2026-04-07 11:01:58 -250.00 0.00 ABGESCHLOSSEN
133 Giro 2026-04-07 12:13:20 -1.00 0.00 ABGESCHLOSSEN
134 Giro 2026-04-08 12:13:34 -1.00 0.00 ABGESCHLOSSEN
135 Giro 2026-04-08 17:41:18 -5.00 0.00 ABGESCHLOSSEN
136 Giro 2026-04-09 12:20:29 -1.00 0.00 ABGESCHLOSSEN
137 Giro 2026-04-09 15:29:19 -5.00 0.00 ABGESCHLOSSEN
138 Giro 2026-04-10 12:25:07 -1.00 0.00 ABGESCHLOSSEN
139 Giro 2026-04-10 17:35:40 -5.00 0.00 ABGESCHLOSSEN
140 Giro 2026-04-10 18:06:55 -0.50 0.00 ABGESCHLOSSEN
141 Giro 2026-04-11 16:55:02 -5.00 0.00 ABGESCHLOSSEN
142 Giro 2026-04-11 16:56:26 -12.50 0.00 ABGESCHLOSSEN
143 Giro 2026-04-11 17:05:47 635.00 0.00 ABGESCHLOSSEN
144 Giro 2026-04-12 20:14:08 -0.84 0.00 ABGESCHLOSSEN
145 Giro 2026-04-13 12:26:16 -1.00 0.00 ABGESCHLOSSEN
146 Giro 2026-04-13 13:44:44 -326.05 0.00 ABGESCHLOSSEN
147 Giro 2026-04-13 16:16:43 -17.16 0.00 ABGESCHLOSSEN
148 Giro 2026-04-13 18:11:28 -1.00 0.00 ABGESCHLOSSEN
149 Giro 2026-04-14 12:05:10 -38.00 0.00 ABGESCHLOSSEN
150 Giro 2026-04-14 12:27:49 -1.00 0.00 ABGESCHLOSSEN
151 Giro 2026-04-14 13:38:24 -1.00 0.00 ABGESCHLOSSEN
152 Giro 2026-04-14 15:24:58 -5.00 0.00 ABGESCHLOSSEN
153 Giro 2026-04-14 17:51:29 -0.45 0.00 ABGESCHLOSSEN
154 Giro 2026-04-15 03:03:40 -24.55 0.00 ABGESCHLOSSEN
155 Giro 2026-04-15 12:11:01 -1.00 0.00 ABGESCHLOSSEN
156 Giro 2026-04-15 15:37:55 -5.00 0.00 ABGESCHLOSSEN
157 Giro 2026-04-16 09:18:01 -0.80 0.00 ABGESCHLOSSEN
158 Giro 2026-04-16 10:35:07 -0.80 0.00 ABGESCHLOSSEN
159 Giro 2026-04-16 12:07:12 -0.50 0.00 ABGESCHLOSSEN
160 Giro 2026-04-16 12:52:23 -0.05 0.00 ABGESCHLOSSEN
161 Giro 2026-04-16 14:38:34 -9.00 0.00 ABGESCHLOSSEN
162 Giro 2026-04-16 14:39:45 -5.00 0.00 ABGESCHLOSSEN
163 Giro 2026-04-16 17:13:32 -4.50 0.00 ABGESCHLOSSEN
164 Giro 2026-04-17 11:23:52 -0.26 0.00 ABGESCHLOSSEN
165 Giro 2026-04-17 14:21:46 -10.20 0.00 ABGESCHLOSSEN
166 Giro 2026-04-17 14:21:53 -11.20 0.00 ABGESCHLOSSEN
167 Giro 2026-04-17 14:41:04 -6.95 0.00 ABGESCHLOSSEN
168 Giro 2026-04-17 19:15:51 -110.00 0.00 ABGESCHLOSSEN
169 Giro 2026-04-17 20:57:51 -50.00 0.00 ABGESCHLOSSEN
170 Giro 2026-04-18 16:55:39 -0.33 0.00 ABGESCHLOSSEN
171 Giro 2026-04-18 19:33:01 -0.05 0.00 ABGESCHLOSSEN
172 Giro 2026-04-19 09:36:13 -22.74 0.00 ABGESCHLOSSEN
173 Giro 2026-04-19 10:39:50 -15.67 0.00 ABGESCHLOSSEN
174 Giro 2026-04-19 10:54:04 -0.70 0.00 ABGESCHLOSSEN
175 Giro 2026-04-19 14:03:57 -16.95 0.00 ABGESCHLOSSEN
176 Giro 2026-04-19 20:24:50 -0.04 0.00 ABGESCHLOSSEN
177 Giro 2026-04-20 11:10:56 -8.30 0.00 ABGESCHLOSSEN
178 Giro 2026-04-20 12:19:07 -1.00 0.00 ABGESCHLOSSEN
179 Giro 2026-04-20 13:38:46 -22.96 0.00 ABGESCHLOSSEN
180 Giro 2026-04-21 12:28:05 -1.00 0.00 ABGESCHLOSSEN
181 Giro 2026-04-21 14:38:51 -5.00 0.00 ABGESCHLOSSEN
182 Giro 2026-04-22 01:00:05 -0.57 0.00 ABGESCHLOSSEN
183 Giro 2026-04-22 02:33:54 -36.55 0.00 ABGESCHLOSSEN
184 Giro 2026-04-22 05:44:52 -5.00 0.00 ABGESCHLOSSEN
185 Giro 2026-04-22 12:05:10 -6.43 0.00 ABGESCHLOSSEN
186 Giro 2026-04-22 12:07:52 -1.00 0.00 ABGESCHLOSSEN
187 Giro 2026-04-22 22:25:17 -0.33 0.00 ABGESCHLOSSEN
188 Giro 2026-04-23 03:34:57 -41.67 0.00 ABGESCHLOSSEN
189 Giro 2026-04-23 05:51:39 -6.00 0.00 ABGESCHLOSSEN
190 Giro 2026-04-23 12:22:59 -1.00 0.00 ABGESCHLOSSEN
191 Giro 2026-04-23 16:57:05 -0.30 0.00 ABGESCHLOSSEN
192 Giro 2026-04-24 05:38:03 -11.70 0.00 ABGESCHLOSSEN
193 Giro 2026-04-24 05:38:10 -5.00 0.00 ABGESCHLOSSEN
194 Giro 2026-04-24 12:23:32 -1.00 0.00 ABGESCHLOSSEN
195 Giro 2026-04-24 14:57:44 -0.25 0.00 ABGESCHLOSSEN
196 Giro 2026-04-24 15:02:08 -0.40 0.00 ABGESCHLOSSEN
197 Giro 2026-04-24 15:04:18 -0.20 0.00 ABGESCHLOSSEN
198 Giro 2026-04-24 18:03:22 500.00 0.00 ABGESCHLOSSEN
199 Giro -98.08 0.00 STORNIERT
200 Giro 2026-04-25 03:32:21 -0.92 0.00 ABGESCHLOSSEN
201 Giro 2026-04-25 05:36:50 -10.75 0.00 ABGESCHLOSSEN
202 Giro 2026-04-25 05:36:52 -2.80 0.00 ABGESCHLOSSEN
203 Giro 2026-04-25 05:36:55 -4.60 0.00 ABGESCHLOSSEN
204 Giro 2026-04-25 05:36:55 -5.00 0.00 ABGESCHLOSSEN
205 Giro 2026-04-25 12:49:00 -0.25 0.00 ABGESCHLOSSEN
206 Giro 2026-04-25 12:59:57 -0.88 0.00 ABGESCHLOSSEN
207 Giro 2026-04-26 05:33:48 -14.75 0.00 ABGESCHLOSSEN
208 Giro 2026-04-26 05:33:55 -98.12 0.00 ABGESCHLOSSEN
209 Giro 2026-04-27 12:22:42 -1.00 0.00 ABGESCHLOSSEN
210 Giro 2026-04-27 13:32:46 500.00 0.00 ABGESCHLOSSEN
211 Giro 2026-04-27 13:32:56 -400.00 0.00 ABGESCHLOSSEN
212 Giro 2026-04-28 06:10:26 -5.00 0.00 ABGESCHLOSSEN
213 Giro 2026-04-28 12:25:23 -0.50 0.00 ABGESCHLOSSEN
214 Giro 2026-04-28 18:05:47 -120.00 0.00 ABGESCHLOSSEN
215 Giro 2026-04-29 02:08:22 0.00 18.99 ABGESCHLOSSEN
216 Giro 2026-04-29 05:46:38 -3.50 0.00 ABGESCHLOSSEN
217 Giro 2026-04-29 12:12:48 -1.00 0.00 ABGESCHLOSSEN
218 Giro 2026-04-29 20:08:53 -0.28 0.00 ABGESCHLOSSEN
219 Giro 2026-04-30 05:42:09 -5.00 0.00 ABGESCHLOSSEN
220 Giro 2026-04-30 07:27:56 500.00 0.00 ABGESCHLOSSEN
221 Giro 2026-04-30 07:28:17 -300.00 0.00 ABGESCHLOSSEN
222 Giro 2026-04-30 07:45:22 -0.80 0.00 ABGESCHLOSSEN
223 Giro 2026-04-30 11:23:32 -0.30 0.00 ABGESCHLOSSEN
224 Giro 2026-04-30 11:26:31 -0.30 0.00 ABGESCHLOSSEN
225 Giro 2026-04-30 15:52:51 -19.20 0.00 ABGESCHLOSSEN
226 Giro 2026-04-30 16:12:13 -8.72 0.00 ABGESCHLOSSEN
227 Giro 2026-04-30 17:44:44 -0.19 0.00 ABGESCHLOSSEN
228 Giro 2026-05-01 05:40:02 -26.70 0.00 ABGESCHLOSSEN
229 Giro 2026-05-01 05:40:05 -10.70 0.00 ABGESCHLOSSEN
230 Giro 2026-05-02 09:17:57 -6.81 0.00 ABGESCHLOSSEN
231 Giro 2026-05-04 04:28:56 -0.77 0.00 ABGESCHLOSSEN
232 Giro 2026-05-04 18:45:45 -0.10 0.00 ABGESCHLOSSEN
233 Giro 2026-05-05 05:46:28 -18.90 0.00 ABGESCHLOSSEN
234 Giro 2026-05-05 05:46:44 -113.23 0.00 ABGESCHLOSSEN
235 Giro 2026-05-05 12:11:03 -1.00 0.00 ABGESCHLOSSEN
236 Giro 2026-05-05 14:51:21 -0.35 0.00 ABGESCHLOSSEN
237 Giro 2026-05-06 06:06:44 -19.65 0.00 ABGESCHLOSSEN
238 Giro 2026-05-06 06:06:50 -5.00 0.00 ABGESCHLOSSEN
239 Giro 2026-05-06 12:11:47 -1.00 0.00 ABGESCHLOSSEN
240 Giro 2026-05-07 05:48:07 -5.00 0.00 ABGESCHLOSSEN
241 Giro 2026-05-07 12:24:35 -1.00 0.00 ABGESCHLOSSEN
242 Giro 2026-05-08 06:51:57 -5.00 0.00 ABGESCHLOSSEN
243 Giro 2026-05-08 12:56:33 -1.00 0.00 ABGESCHLOSSEN
244 Giro 2026-05-09 06:14:42 -5.00 0.00 ABGESCHLOSSEN
245 Giro 2026-05-09 14:05:03 -0.60 0.00 ABGESCHLOSSEN
246 Giro 2026-05-10 03:06:49 -17.40 0.00 ABGESCHLOSSEN
247 Giro 2026-05-11 12:13:29 -1.00 0.00 ABGESCHLOSSEN
248 Giro 2026-05-12 05:57:05 -5.00 0.00 ABGESCHLOSSEN
249 Giro 2026-05-12 12:11:58 -1.00 0.00 ABGESCHLOSSEN
250 Giro 2026-05-13 06:02:32 -5.00 0.00 ABGESCHLOSSEN
251 Giro 2026-05-13 11:48:16 -0.95 0.00 ABGESCHLOSSEN
252 Giro 2026-05-13 12:59:20 -1.00 0.00 ABGESCHLOSSEN
253 Giro 2026-05-13 19:11:45 -0.17 0.00 ABGESCHLOSSEN
254 Giro 2026-05-13 20:38:44 -0.66 0.00 ABGESCHLOSSEN
255 Giro 2026-05-14 06:01:53 -19.05 0.00 ABGESCHLOSSEN
256 Giro 2026-05-14 06:02:02 -10.00 0.00 ABGESCHLOSSEN
257 Giro 2026-05-14 10:04:25 -7.34 0.00 ABGESCHLOSSEN
258 Giro 2026-05-14 15:00:51 -0.85 0.00 ABGESCHLOSSEN
259 Giro 2026-05-14 15:57:46 -18.83 0.00 ABGESCHLOSSEN
260 Giro 2026-05-14 16:40:47 -0.99 0.00 ABGESCHLOSSEN
261 Giro 2026-05-14 16:56:24 -0.20 0.00 ABGESCHLOSSEN
262 Giro 2026-05-15 13:46:19 -0.45 0.00 ABGESCHLOSSEN
263 Giro 2026-05-15 14:38:44 -0.93 0.00 ABGESCHLOSSEN
264 Giro 2026-05-15 15:37:15 -0.34 0.00 ABGESCHLOSSEN
265 Giro 2026-05-15 16:36:49 -9.15 0.00 ABGESCHLOSSEN
266 Giro 2026-05-15 16:37:02 -9.01 0.00 ABGESCHLOSSEN
267 Giro 2026-05-15 16:37:04 -8.80 0.00 ABGESCHLOSSEN
268 Giro 2026-05-15 17:21:07 -0.21 0.00 ABGESCHLOSSEN
269 Giro 2026-05-15 17:58:24 -0.33 0.00 ABGESCHLOSSEN
270 Giro 2026-05-16 06:10:13 -7.79 0.00 ABGESCHLOSSEN
271 Giro 2026-05-16 12:48:49 -0.12 0.00 ABGESCHLOSSEN
272 Giro 2026-05-16 12:49:41 -0.67 0.00 ABGESCHLOSSEN
273 Giro 2026-05-16 13:53:23 -21.07 0.00 ABGESCHLOSSEN
274 Giro 2026-05-16 13:53:28 -30.66 0.00 ABGESCHLOSSEN
275 Giro 2026-05-16 13:53:29 -7.67 0.00 ABGESCHLOSSEN
276 Giro 2026-05-16 13:53:35 -11.55 0.00 ABGESCHLOSSEN
277 Giro 2026-05-16 14:06:38 -0.08 0.00 ABGESCHLOSSEN
278 Giro 2026-05-16 14:36:40 -0.96 0.00 ABGESCHLOSSEN
279 Giro 2026-05-16 15:22:19 -0.52 0.00 ABGESCHLOSSEN
280 Giro 2026-05-16 21:22:56 -0.76 0.00 ABGESCHLOSSEN
281 Giro 2026-05-17 05:44:20 -5.04 0.00 ABGESCHLOSSEN
282 Giro 2026-05-17 09:00:32 -0.12 0.00 ABGESCHLOSSEN
283 Giro 2026-05-17 09:30:32 -5.48 0.00 ABGESCHLOSSEN
284 Giro 2026-05-17 09:30:35 -22.92 0.00 ABGESCHLOSSEN
285 Giro 2026-05-17 09:30:36 -4.33 0.00 ABGESCHLOSSEN
286 Giro 2026-05-17 09:30:42 -7.24 0.00 ABGESCHLOSSEN
287 Giro 2026-05-17 15:29:24 -6.88 0.00 ABGESCHLOSSEN
288 Giro 2026-05-18 12:17:49 -1.00 0.00 ABGESCHLOSSEN
289 Giro 2026-05-18 12:31:04 180.00 0.00 ABGESCHLOSSEN
290 Giro 2026-05-18 14:15:34 -0.10 0.00 ABGESCHLOSSEN
291 Giro 2026-05-18 19:31:25 -0.99 0.00 ABGESCHLOSSEN
292 Giro 2026-05-18 20:28:55 -0.87 0.00 ABGESCHLOSSEN
293 Giro 2026-05-19 05:50:54 -7.90 0.00 ABGESCHLOSSEN
294 Giro 2026-05-19 05:50:59 -5.00 0.00 ABGESCHLOSSEN
295 Giro 2026-05-19 09:46:35 -22.88 0.00 ABGESCHLOSSEN
296 Giro 2026-05-19 12:19:05 -1.00 0.00 ABGESCHLOSSEN
297 Giro 2026-05-19 16:12:19 -17.01 0.00 ABGESCHLOSSEN
298 Giro 2026-05-19 16:12:25 -68.13 0.00 ABGESCHLOSSEN
299 Giro 2026-05-20 05:45:45 -5.00 0.00 ABGESCHLOSSEN
300 Giro 2026-05-20 12:23:55 -0.50 0.00 ABGESCHLOSSEN
301 Giro 2026-05-21 05:45:44 -3.50 0.00 ABGESCHLOSSEN
302 Giro 2026-05-21 11:24:20 -0.10 0.00 ABGESCHLOSSEN
303 Giro 2026-05-21 12:31:14 -0.80 0.00 ABGESCHLOSSEN
304 Giro 2026-05-22 00:52:30 -0.59 0.00 ABGESCHLOSSEN
305 Giro 2026-05-22 05:39:22 -13.90 0.00 ABGESCHLOSSEN
306 Giro 2026-05-22 05:39:29 -8.20 0.00 ABGESCHLOSSEN
307 Giro 2026-05-22 15:13:55 400.00 0.00 ABGESCHLOSSEN
308 Giro 2026-05-22 15:14:28 -150.00 0.00 ABGESCHLOSSEN
309 Giro 2026-05-22 16:05:53 -6.41 0.00 ABGESCHLOSSEN
310 Giro 2026-05-25 13:59:41 500.00 0.00 ABGESCHLOSSEN
311 Giro 2026-05-25 14:00:07 -500.00 0.00 ABGESCHLOSSEN
312 Giro 2026-05-26 12:22:31 -0.50 0.00 ABGESCHLOSSEN
313 Giro 2026-05-26 16:03:14 -0.70 0.00 ABGESCHLOSSEN
314 Giro 2026-05-27 06:03:18 -3.30 0.00 ABGESCHLOSSEN
315 Giro 2026-05-27 06:03:27 -3.50 0.00 ABGESCHLOSSEN
316 Giro 2026-05-27 06:31:32 300.00 0.00 ABGESCHLOSSEN
317 Giro 2026-05-27 06:31:49 -200.00 0.00 ABGESCHLOSSEN
318 Giro 2026-05-27 12:18:19 -1.00 0.00 ABGESCHLOSSEN
319 Giro 2026-05-27 16:29:08 300.00 0.00 ABGESCHLOSSEN
320 Giro 2026-05-28 05:56:36 -5.00 0.00 ABGESCHLOSSEN
321 Giro 2026-05-28 10:59:23 -0.71 0.00 ABGESCHLOSSEN
322 Giro 2026-05-28 12:17:27 -1.00 0.00 ABGESCHLOSSEN
323 Giro 2026-05-28 17:11:20 -12.29 0.00 ABGESCHLOSSEN
324 Giro 2026-05-28 17:11:56 -0.65 0.00 ABGESCHLOSSEN
325 Giro 2026-05-29 02:17:35 0.00 18.99 ABGESCHLOSSEN
326 Giro 2026-05-29 06:04:09 -5.35 0.00 ABGESCHLOSSEN
327 Giro 2026-05-29 06:04:15 -5.00 0.00 ABGESCHLOSSEN
328 Giro 2026-05-29 06:37:30 600.00 0.00 ABGESCHLOSSEN
329 Giro 2026-05-29 12:24:17 -1.00 0.00 ABGESCHLOSSEN
330 Giro 2026-05-29 15:49:33 -1.00 0.00 ABGESCHLOSSEN
331 Giro 2026-05-29 16:13:57 -0.85 0.00 ABGESCHLOSSEN
332 Giro 2026-05-30 21:46:07 -2.15 0.00 ABGESCHLOSSEN
333 Giro 2026-05-30 21:46:21 -5.00 0.00 ABGESCHLOSSEN
334 Giro 2026-05-31 16:46:20 -509.00 0.00 ABGESCHLOSSEN
335 Giro 2026-06-01 07:17:06 508.00 0.00 ABGESCHLOSSEN
336 Giro 2026-06-01 09:11:11 -0.20 0.00 ABGESCHLOSSEN
337 Giro 2026-06-01 09:28:23 -71.50 0.00 ABGESCHLOSSEN
338 Giro 2026-06-01 09:28:23 -493.00 0.00 ABGESCHLOSSEN
339 Giro 2026-06-01 12:17:49 -1.00 0.00 ABGESCHLOSSEN
340 Giro 2026-06-02 06:22:14 -3.80 0.00 ABGESCHLOSSEN
341 Giro 2026-06-02 06:23:31 -5.00 0.00 ABGESCHLOSSEN
342 Giro 2026-06-02 12:33:11 -1.00 0.00 ABGESCHLOSSEN
343 Giro 2026-06-03 05:48:21 -5.00 0.00 ABGESCHLOSSEN
344 Giro 2026-06-03 06:59:35 -0.05 0.00 ABGESCHLOSSEN
345 Giro 2026-06-03 08:32:08 -0.80 0.00 ABGESCHLOSSEN
346 Giro 2026-06-03 18:24:55 -0.15 0.00 ABGESCHLOSSEN
347 Giro 2026-06-03 19:09:31 -200.00 0.00 ABGESCHLOSSEN
348 Giro 2026-06-03 19:12:46 500.00 0.00 ABGESCHLOSSEN
349 Giro 2026-06-03 19:13:07 -200.00 0.00 ABGESCHLOSSEN
350 Giro 2026-06-04 06:39:06 -19.95 0.00 ABGESCHLOSSEN
351 Giro 2026-06-04 06:39:41 -8.20 0.00 ABGESCHLOSSEN
352 Giro 2026-06-04 11:36:52 -16.85 0.00 ABGESCHLOSSEN
353 Giro 2026-06-05 10:33:28 -0.52 0.00 ABGESCHLOSSEN
354 Giro 2026-06-05 13:19:02 -0.26 0.00 ABGESCHLOSSEN
355 Giro 2026-06-05 18:37:43 -0.40 0.00 ABGESCHLOSSEN
356 Giro 2026-06-06 05:39:54 -1.74 0.00 ABGESCHLOSSEN
357 Giro 2026-06-06 05:39:54 -30.48 0.00 ABGESCHLOSSEN
358 Giro 2026-06-06 05:40:20 -4.60 0.00 ABGESCHLOSSEN
359 Giro 2026-06-06 14:02:29 -0.41 0.00 ABGESCHLOSSEN
360 Giro 2026-06-07 05:43:00 -72.59 0.00 ABGESCHLOSSEN
361 Giro 2026-06-07 10:51:57 -0.45 0.00 ABGESCHLOSSEN
362 Giro 2026-06-07 10:58:42 -0.76 0.00 ABGESCHLOSSEN
363 Giro 2026-06-08 06:02:21 -9.55 0.00 ABGESCHLOSSEN
364 Giro 2026-06-08 11:28:49 -10.24 0.00 ABGESCHLOSSEN
365 Giro 2026-06-08 12:11:23 -1.00 0.00 ABGESCHLOSSEN
366 Giro 2026-06-09 05:42:52 -5.00 0.00 ABGESCHLOSSEN
367 Giro 2026-06-09 12:18:52 -1.00 0.00 ABGESCHLOSSEN
368 Giro 2026-06-10 05:37:43 -5.00 0.00 ABGESCHLOSSEN
369 Giro 2026-06-10 12:08:16 -1.00 0.00 ABGESCHLOSSEN
370 Giro 2026-06-11 05:52:08 -5.00 0.00 ABGESCHLOSSEN
371 Giro 2026-06-11 12:10:11 -1.00 0.00 ABGESCHLOSSEN
372 Giro 2026-06-12 09:15:54 -5.00 0.00 ABGESCHLOSSEN
373 Giro 2026-06-12 12:22:18 -0.50 0.00 ABGESCHLOSSEN
374 Giro 2026-06-13 05:47:46 -3.50 0.00 ABGESCHLOSSEN
375 Giro 2026-06-13 15:05:41 -0.80 0.00 ABGESCHLOSSEN
376 Giro 2026-06-14 05:20:27 -31.20 0.00 ABGESCHLOSSEN
377 Giro 2026-06-15 12:23:52 -1.00 0.00 ABGESCHLOSSEN
378 Giro 2026-06-16 05:39:09 -5.00 0.00 ABGESCHLOSSEN
379 Giro 2026-06-16 12:31:23 -0.10 0.00 ABGESCHLOSSEN
380 Giro 2026-06-16 19:26:49 -0.10 0.00 ABGESCHLOSSEN
381 Giro 2026-06-17 03:00:18 -69.90 0.00 ABGESCHLOSSEN
382 Giro 2026-06-17 05:50:36 -4.90 0.00 ABGESCHLOSSEN
383 Giro 2026-06-17 12:25:06 -1.00 0.00 ABGESCHLOSSEN
384 Giro 2026-06-18 05:44:13 -5.00 0.00 ABGESCHLOSSEN
385 Giro 2026-06-18 12:23:28 -1.00 0.00 ABGESCHLOSSEN
386 Giro 2026-06-18 17:41:34 -0.20 0.00 ABGESCHLOSSEN
387 Giro 2026-06-18 21:31:12 -0.53 0.00 ABGESCHLOSSEN
388 Giro 2026-06-19 05:46:23 -19.80 0.00 ABGESCHLOSSEN
389 Giro 2026-06-19 05:47:07 -5.00 0.00 ABGESCHLOSSEN
390 Giro 2026-06-19 12:22:41 -0.50 0.00 ABGESCHLOSSEN
391 Giro 2026-06-19 14:33:34 -17.47 0.00 ABGESCHLOSSEN
392 Giro 2026-06-20 05:44:57 -3.50 0.00 ABGESCHLOSSEN
393 Giro -63.94 0.00 STORNIERT
394 Giro 2026-06-21 13:00:04 -0.06 0.00 ABGESCHLOSSEN
395 Giro 2026-06-22 00:41:06 -0.51 0.00 ABGESCHLOSSEN
396 Giro 2026-06-22 10:02:41 -6.49 0.00 ABGESCHLOSSEN
397 Giro 2026-06-22 12:24:12 -1.00 0.00 ABGESCHLOSSEN
398 Giro 2026-06-22 13:57:39 -0.23 0.00 ABGESCHLOSSEN
399 Giro 2026-06-22 15:43:43 -1.00 0.00 ABGESCHLOSSEN
400 Giro 2026-06-22 17:13:45 -0.50 0.00 ABGESCHLOSSEN
401 Giro 2026-06-22 17:18:11 -0.36 0.00 ABGESCHLOSSEN
402 Giro 2026-06-22 20:44:49 -0.30 0.00 ABGESCHLOSSEN
403 Giro 2026-06-23 05:58:20 -3.70 0.00 ABGESCHLOSSEN
404 Giro 2026-06-23 05:58:27 -10.00 0.00 ABGESCHLOSSEN
405 Giro 2026-06-23 05:59:13 -5.00 0.00 ABGESCHLOSSEN
406 Giro 2026-06-23 06:01:18 -92.64 0.00 ABGESCHLOSSEN
407 Giro 2026-06-23 10:14:09 -78.77 0.00 ABGESCHLOSSEN
408 Giro 2026-06-24 14:04:08 -7.50 0.00 ABGESCHLOSSEN
409 Giro 2026-06-24 17:59:54 -0.98 0.00 ABGESCHLOSSEN
410 Giro 2026-06-25 10:24:46 -0.26 0.00 ABGESCHLOSSEN
411 Giro 2026-06-25 10:26:07 -0.24 0.00 ABGESCHLOSSEN
412 Giro 2026-06-26 05:56:49 -44.76 0.00 ABGESCHLOSSEN
413 Giro 2026-06-26 05:56:49 -14.74 0.00 ABGESCHLOSSEN
414 Giro 2026-06-26 13:52:49 -33.02 0.00 ABGESCHLOSSEN
415 Giro 2026-06-27 08:36:36 -82.00 0.00 ABGESCHLOSSEN
416 Giro 2026-06-27 21:29:06 500.00 0.00 ABGESCHLOSSEN
417 Giro 2026-06-28 12:00:48 -0.38 0.00 ABGESCHLOSSEN
418 Giro 2026-06-29 02:14:30 0.00 18.99 ABGESCHLOSSEN
419 Giro 2026-06-29 12:31:07 -1.00 0.00 ABGESCHLOSSEN
420 Giro 2026-06-29 15:44:23 -12.62 0.00 ABGESCHLOSSEN
421 Giro 2026-06-30 06:11:27 -5.00 0.00 ABGESCHLOSSEN
422 Giro 2026-06-30 12:27:44 -1.00 0.00 ABGESCHLOSSEN
423 Giro 2026-07-01 05:54:30 -5.00 0.00 ABGESCHLOSSEN
424 Giro 2026-07-01 10:13:09 -288.00 0.00 ABGESCHLOSSEN
425 Giro 2026-07-01 12:09:30 -0.50 0.00 ABGESCHLOSSEN
426 Giro 2026-07-01 15:35:09 -0.60 0.00 ABGESCHLOSSEN
427 Giro 2026-07-02 10:53:34 -9.40 0.00 ABGESCHLOSSEN
428 Giro 2026-07-02 10:55:02 -3.50 0.00 ABGESCHLOSSEN
429 Giro 2026-07-02 12:17:13 -1.00 0.00 ABGESCHLOSSEN
430 Giro 2026-07-02 16:52:24 -0.75 0.00 ABGESCHLOSSEN
431 Giro 2026-07-03 05:50:20 -6.25 0.00 ABGESCHLOSSEN
432 Giro 2026-07-03 05:50:54 -5.00 0.00 ABGESCHLOSSEN
433 Giro 2026-07-03 12:21:15 -1.00 0.00 ABGESCHLOSSEN
434 Giro 2026-07-03 15:03:14 -0.35 0.00 ABGESCHLOSSEN
435 Giro 2026-07-03 21:56:51 -0.03 0.00 ABGESCHLOSSEN
436 Giro 2026-07-03 22:07:20 -0.77 0.00 ABGESCHLOSSEN
437 Giro 2026-07-04 05:59:23 -4.65 0.00 ABGESCHLOSSEN
438 Giro 2026-07-04 06:00:18 -5.00 0.00 ABGESCHLOSSEN
439 Giro 2026-07-04 07:50:56 -0.63 0.00 ABGESCHLOSSEN
440 Giro 2026-07-04 10:00:24 -1.00 0.00 ABGESCHLOSSEN
441 Giro 2026-07-04 14:37:41 -9.23 0.00 ABGESCHLOSSEN
442 Giro 2026-07-04 14:37:41 -10.97 0.00 ABGESCHLOSSEN
443 Giro 2026-07-05 05:37:15 -33.00 0.00 ABGESCHLOSSEN
444 Giro 2026-07-06 05:52:43 -0.37 0.00 ABGESCHLOSSEN
445 Giro 2026-07-06 12:27:02 -1.00 0.00 ABGESCHLOSSEN
446 Giro -50.81 0.00 STORNIERT
447 Giro 2026-07-06 12:30:05 -0.19 0.00 ABGESCHLOSSEN
448 Giro 2026-07-07 06:16:28 -5.00 0.00 ABGESCHLOSSEN
449 Giro 2026-07-07 12:32:47 -1.00 0.00 ABGESCHLOSSEN
450 Giro 2026-07-08 05:55:58 -5.00 0.00 ABGESCHLOSSEN
451 Giro 2026-07-08 12:17:18 -1.00 0.00 ABGESCHLOSSEN
452 Giro 2026-07-08 16:38:38 -0.25 0.00 ABGESCHLOSSEN
453 Giro 2026-07-08 16:41:23 -0.80 0.00 ABGESCHLOSSEN
454 Giro 2026-07-08 16:48:09 -1.00 0.00 ABGESCHLOSSEN
455 Giro 2026-07-08 16:54:34 300.00 0.00 ABGESCHLOSSEN
456 Giro 2026-07-08 18:03:27 -1.00 0.00 ABGESCHLOSSEN
457 Giro 2026-07-08 21:40:47 -0.10 0.00 ABGESCHLOSSEN
458 Giro 2026-07-09 05:45:42 -2.00 0.00 ABGESCHLOSSEN
459 Giro 2026-07-09 05:45:45 -22.75 0.00 ABGESCHLOSSEN
460 Giro 2026-07-09 05:46:25 -10.00 0.00 ABGESCHLOSSEN
461 Giro 2026-07-09 05:46:29 -5.00 0.00 ABGESCHLOSSEN
462 Giro 2026-07-09 05:47:07 -8.20 0.00 ABGESCHLOSSEN
463 Giro 2026-07-09 08:42:46 -0.28 0.00 ABGESCHLOSSEN
464 Giro 2026-07-09 09:20:29 -74.90 0.00 ABGESCHLOSSEN
465 Giro 2026-07-09 17:55:18 -100.00 0.00 ABGESCHLOSSEN
466 Giro 2026-07-10 03:16:29 -27.72 0.00 ABGESCHLOSSEN
467 Giro 2026-07-13 12:22:52 -1.00 0.00 ABGESCHLOSSEN
468 Giro 2026-07-13 15:10:12 -0.55 0.00 ABGESCHLOSSEN
469 Giro 2026-07-14 05:58:53 -20.45 0.00 ABGESCHLOSSEN
470 Giro 2026-07-14 05:59:29 -5.00 0.00 ABGESCHLOSSEN
471 Giro 2026-07-14 12:23:58 -1.00 0.00 ABGESCHLOSSEN
472 Giro 2026-07-15 05:57:18 -5.00 0.00 ABGESCHLOSSEN
473 Giro 2026-07-15 12:08:52 -1.00 0.00 ABGESCHLOSSEN
474 Giro 2026-07-16 05:37:15 -5.00 0.00 ABGESCHLOSSEN
475 Giro 2026-07-16 09:00:30 -0.57 0.00 ABGESCHLOSSEN
476 Giro 2026-07-16 12:22:05 -1.00 0.00 ABGESCHLOSSEN
477 Giro 2026-07-17 05:52:10 -5.00 0.00 ABGESCHLOSSEN
478 Giro 2026-07-17 11:36:24 -1.00 0.00 ABGESCHLOSSEN
479 Giro 2026-07-17 14:41:39 -0.25 0.00 ABGESCHLOSSEN
480 Giro 2026-07-18 05:57:24 -13.75 0.00 ABGESCHLOSSEN
481 Giro 2026-07-18 05:57:44 -6.00 0.00 ABGESCHLOSSEN
482 Giro 2026-07-18 11:07:39 -23.43 0.00 ABGESCHLOSSEN
483 Giro 2026-07-20 11:47:14 -1.00 0.00 ABGESCHLOSSEN
484 Giro 2026-07-21 06:09:34 -5.00 0.00 ABGESCHLOSSEN
485 Giro 2026-07-21 16:12:22 -0.05 0.00 ABGESCHLOSSEN
486 Giro 2026-07-21 18:02:22 200.00 0.00 ABGESCHLOSSEN
487 Giro 2026-07-22 05:46:31 -8.95 0.00 ABGESCHLOSSEN
488 Giro 2026-07-22 08:44:05 -50.00 0.00 ABGESCHLOSSEN
489 Giro 2026-07-22 11:57:45 -1.00 0.00 ABGESCHLOSSEN
490 Giro 2026-07-23 07:31:54 -5.00 0.00 ABGESCHLOSSEN
491 Giro 2026-07-23 11:57:22 -1.00 0.00 ABGESCHLOSSEN
492 Giro 2026-07-24 06:01:57 -5.00 0.00 ABGESCHLOSSEN
493 Giro 2026-07-24 11:48:49 -1.00 0.00 ABGESCHLOSSEN
494 Giro 2026-07-24 14:25:04 -0.45 0.00 ABGESCHLOSSEN
495 Giro 2026-07-25 06:42:57 -5.00 0.00 ABGESCHLOSSEN
496 Giro 2026-07-25 20:57:07 -3.55 0.00 ABGESCHLOSSEN
497 Giro 2026-07-26 18:50:39 -0.70 0.00 ABGESCHLOSSEN
498 Giro 2026-07-27 05:50:40 -29.30 0.00 ABGESCHLOSSEN
499 Giro 2026-07-28 12:01:05 -0.23 0.00 ABGESCHLOSSEN
500 Giro 2026-07-29 02:28:20 0.00 20.99 ABGESCHLOSSEN
501 Giro 2026-07-29 12:05:27 -41.09 0.00 ABGESCHLOSSEN
502 Giro 2026-07-29 14:07:25 -12.77 0.00 ABGESCHLOSSEN
503 Giro 2026-07-29 18:44:44 500.00 0.00 ABGESCHLOSSEN
504 Giro 2026-07-30 09:56:27 -25.57 0.00 ABGESCHLOSSEN
505 Giro 2026-07-30 14:57:11 -0.51 0.00 ABGESCHLOSSEN
506 Giro 2026-07-30 15:27:59 -0.96 0.00 ABGESCHLOSSEN
507 Giro 2026-07-31 03:12:39 -28.49 0.00 ABGESCHLOSSEN
508 Giro 2026-07-31 10:13:40 -197.04 0.00 ABGESCHLOSSEN
509 Giro 2026-07-31 15:49:42 -0.94 0.00 ABGESCHLOSSEN
510 Giro 2026-08-01 05:28:43 -29.06 0.00 ABGESCHLOSSEN
511 Giro 2026-08-01 12:32:07 -0.01 0.00 ABGESCHLOSSEN
512 Giro 2026-08-01 12:36:08 300.00 0.00 ABGESCHLOSSEN
513 Giro 2026-08-01 12:36:41 -300.00 0.00 ABGESCHLOSSEN
514 Giro 2026-08-02 09:21:34 -14.99 0.00 ABGESCHLOSSEN
515 Giro 2026-08-05 15:00:39 -1.00 0.00 ABGESCHLOSSEN
516 Giro 2026-08-05 16:08:01 -0.87 0.00 ABGESCHLOSSEN
517 Giro 2026-08-05 16:12:53 -130.00 0.00 ABGESCHLOSSEN
518 Giro 2026-08-06 07:39:38 -2.13 0.00 ABGESCHLOSSEN
519 Giro 2026-08-07 06:01:40 -11.00 0.00 ABGESCHLOSSEN
520 Giro 2026-08-08 13:55:31 -0.76 0.00 ABGESCHLOSSEN
521 Giro 2026-08-09 09:08:49 -68.24 0.00 ABGESCHLOSSEN
522 Giro 2026-08-11 15:06:56 300.00 0.00 ABGESCHLOSSEN
523 Giro 2026-08-11 15:09:15 -0.89 0.00 ABGESCHLOSSEN
524 Giro 2026-08-11 15:14:08 -0.14 0.00 ABGESCHLOSSEN
525 Giro 2026-08-12 06:08:26 -11.11 0.00 ABGESCHLOSSEN
526 Giro 2026-08-12 09:44:51 -1.86 0.00 ABGESCHLOSSEN
527 Giro 2026-08-12 09:47:17 -0.91 0.00 ABGESCHLOSSEN
528 Giro 2026-08-12 11:27:41 -150.00 0.00 ABGESCHLOSSEN
529 Giro 2026-08-13 09:31:24 -11.09 0.00 ABGESCHLOSSEN
530 Giro 2026-08-15 09:00:24 -0.35 0.00 ABGESCHLOSSEN
531 Giro 2026-08-16 18:58:39 -47.16 0.00 ABGESCHLOSSEN
532 Giro 2026-08-17 12:28:01 -0.94 0.00 ABGESCHLOSSEN
533 Giro 2026-08-18 03:17:33 -23.65 0.00 ABGESCHLOSSEN
534 Giro 2026-08-18 09:59:09 -4.06 0.00 ABGESCHLOSSEN
535 Erspartes 2026-01-09 08:35:23 0.09 0.00 ABGESCHLOSSEN
536 Erspartes 2026-01-22 23:18:19 0.62 0.00 ABGESCHLOSSEN
537 Erspartes 2026-02-16 11:44:57 0.13 0.00 ABGESCHLOSSEN
538 Erspartes 2026-02-22 00:53:01 0.60 0.00 ABGESCHLOSSEN
539 Erspartes 2026-02-25 22:39:06 250.00 0.00 ABGESCHLOSSEN
540 Erspartes 2026-03-01 22:29:19 0.50 0.00 ABGESCHLOSSEN
541 Erspartes 2026-03-02 12:23:17 1.00 0.00 ABGESCHLOSSEN
542 Erspartes 2026-03-03 12:10:14 1.00 0.00 ABGESCHLOSSEN
543 Erspartes 2026-03-03 13:46:18 0.10 0.00 ABGESCHLOSSEN
544 Erspartes 2026-03-04 12:32:28 1.00 0.00 ABGESCHLOSSEN
545 Erspartes 2026-03-05 12:12:22 1.00 0.00 ABGESCHLOSSEN
546 Erspartes 2026-03-05 17:51:07 0.65 0.00 ABGESCHLOSSEN
547 Erspartes 2026-03-06 12:26:46 1.00 0.00 ABGESCHLOSSEN
548 Erspartes 2026-03-08 12:35:06 0.51 0.00 ABGESCHLOSSEN
549 Erspartes 2026-03-09 12:33:54 1.00 0.00 ABGESCHLOSSEN
550 Erspartes 2026-03-10 12:19:07 1.00 0.00 ABGESCHLOSSEN
551 Erspartes 2026-03-13 09:49:50 -250.00 0.00 ABGESCHLOSSEN
552 Erspartes 2026-03-13 12:09:24 1.00 0.00 ABGESCHLOSSEN
553 Erspartes 2026-03-14 06:59:13 0.25 0.00 ABGESCHLOSSEN
554 Erspartes 2026-03-16 20:10:07 -14.00 0.00 ABGESCHLOSSEN
555 Erspartes 2026-03-18 21:06:13 1.00 0.00 ABGESCHLOSSEN
556 Erspartes 2026-03-22 00:53:05 0.61 0.00 ABGESCHLOSSEN
557 Erspartes 2026-03-24 09:07:28 0.50 0.00 ABGESCHLOSSEN
558 Erspartes 2026-03-24 11:58:48 0.20 0.00 ABGESCHLOSSEN
559 Erspartes 2026-03-24 12:01:21 0.40 0.00 ABGESCHLOSSEN
560 Erspartes 2026-03-25 12:09:47 1.00 0.00 ABGESCHLOSSEN
561 Erspartes 2026-03-26 12:29:53 1.00 0.00 ABGESCHLOSSEN
562 Erspartes 2026-03-26 18:08:29 0.78 0.00 ABGESCHLOSSEN
563 Erspartes 2026-03-26 18:09:19 0.18 0.00 ABGESCHLOSSEN
564 Erspartes 2026-03-26 18:38:13 0.64 0.00 ABGESCHLOSSEN
565 Erspartes 2026-03-26 18:51:15 0.14 0.00 ABGESCHLOSSEN
566 Erspartes 2026-03-26 19:56:25 0.49 0.00 ABGESCHLOSSEN
567 Erspartes 2026-03-27 11:50:22 0.40 0.00 ABGESCHLOSSEN
568 Erspartes 2026-03-27 12:59:06 0.90 0.00 ABGESCHLOSSEN
569 Erspartes 2026-03-29 15:23:03 1.00 0.00 ABGESCHLOSSEN
570 Erspartes 2026-03-31 08:53:49 0.60 0.00 ABGESCHLOSSEN
571 Erspartes 2026-03-31 08:54:52 0.60 0.00 ABGESCHLOSSEN
572 Erspartes 2026-03-31 08:55:56 0.60 0.00 ABGESCHLOSSEN
573 Erspartes 2026-03-31 08:56:55 0.60 0.00 ABGESCHLOSSEN
574 Erspartes 2026-03-31 12:24:31 1.00 0.00 ABGESCHLOSSEN
575 Erspartes 2026-03-31 16:32:12 0.55 0.00 ABGESCHLOSSEN
576 Erspartes 2026-04-01 12:10:56 1.00 0.00 ABGESCHLOSSEN
577 Erspartes 2026-04-02 17:17:20 0.90 0.00 ABGESCHLOSSEN
578 Erspartes 2026-04-07 12:13:20 1.00 0.00 ABGESCHLOSSEN
579 Erspartes 2026-04-08 12:13:34 1.00 0.00 ABGESCHLOSSEN
580 Erspartes 2026-04-09 12:20:29 1.00 0.00 ABGESCHLOSSEN
581 Erspartes 2026-04-10 12:25:07 1.00 0.00 ABGESCHLOSSEN
582 Erspartes 2026-04-10 18:06:55 0.50 0.00 ABGESCHLOSSEN
583 Erspartes 2026-04-12 20:14:08 0.84 0.00 ABGESCHLOSSEN
584 Erspartes 2026-04-13 12:26:16 1.00 0.00 ABGESCHLOSSEN
585 Erspartes 2026-04-13 18:11:28 1.00 0.00 ABGESCHLOSSEN
586 Erspartes 2026-04-14 12:27:49 1.00 0.00 ABGESCHLOSSEN
587 Erspartes 2026-04-14 13:38:24 1.00 0.00 ABGESCHLOSSEN
588 Erspartes 2026-04-14 17:51:29 0.45 0.00 ABGESCHLOSSEN
589 Erspartes 2026-04-15 12:11:01 1.00 0.00 ABGESCHLOSSEN
590 Erspartes 2026-04-16 09:18:01 0.80 0.00 ABGESCHLOSSEN
591 Erspartes 2026-04-16 10:35:07 0.80 0.00 ABGESCHLOSSEN
592 Erspartes 2026-04-16 12:07:12 0.50 0.00 ABGESCHLOSSEN
593 Erspartes 2026-04-16 12:52:23 0.05 0.00 ABGESCHLOSSEN
594 Erspartes 2026-04-17 11:23:52 0.26 0.00 ABGESCHLOSSEN
595 Erspartes 2026-04-17 19:17:03 0.91 0.00 ABGESCHLOSSEN
596 Erspartes 2026-04-18 16:55:39 0.33 0.00 ABGESCHLOSSEN
597 Erspartes 2026-04-18 19:33:01 0.05 0.00 ABGESCHLOSSEN
598 Erspartes 2026-04-19 10:54:04 0.70 0.00 ABGESCHLOSSEN
599 Erspartes 2026-04-19 20:24:50 0.04 0.00 ABGESCHLOSSEN
600 Erspartes 2026-04-20 12:19:07 1.00 0.00 ABGESCHLOSSEN
601 Erspartes 2026-04-21 12:28:05 1.00 0.00 ABGESCHLOSSEN
602 Erspartes 2026-04-22 01:00:05 0.57 0.00 ABGESCHLOSSEN
603 Erspartes 2026-04-22 12:07:52 1.00 0.00 ABGESCHLOSSEN
604 Erspartes 2026-04-22 22:25:17 0.33 0.00 ABGESCHLOSSEN
605 Erspartes 2026-04-23 12:22:59 1.00 0.00 ABGESCHLOSSEN
606 Erspartes 2026-04-23 16:57:05 0.30 0.00 ABGESCHLOSSEN
607 Erspartes 2026-04-24 12:23:32 1.00 0.00 ABGESCHLOSSEN
608 Erspartes 2026-04-24 14:57:44 0.25 0.00 ABGESCHLOSSEN
609 Erspartes 2026-04-24 15:02:08 0.40 0.00 ABGESCHLOSSEN
610 Erspartes 2026-04-24 15:04:18 0.20 0.00 ABGESCHLOSSEN
611 Erspartes 2026-04-25 03:32:21 0.92 0.00 ABGESCHLOSSEN
612 Erspartes 2026-04-25 12:49:00 0.25 0.00 ABGESCHLOSSEN
613 Erspartes 2026-04-25 12:59:57 0.88 0.00 ABGESCHLOSSEN
614 Erspartes 2026-04-27 12:22:42 1.00 0.00 ABGESCHLOSSEN
615 Erspartes 2026-04-27 21:18:04 0.91 0.00 ABGESCHLOSSEN
616 Erspartes 2026-04-28 12:25:23 0.50 0.00 ABGESCHLOSSEN
617 Erspartes 2026-04-29 12:12:48 1.00 0.00 ABGESCHLOSSEN
618 Erspartes 2026-04-29 18:07:51 0.19 0.00 ABGESCHLOSSEN
619 Erspartes 2026-04-29 18:55:39 0.12 0.00 ABGESCHLOSSEN
620 Erspartes 2026-04-29 20:08:53 0.28 0.00 ABGESCHLOSSEN
621 Erspartes 2026-04-30 07:45:22 0.80 0.00 ABGESCHLOSSEN
622 Erspartes 2026-04-30 11:23:32 0.30 0.00 ABGESCHLOSSEN
623 Erspartes 2026-04-30 11:26:31 0.30 0.00 ABGESCHLOSSEN
624 Erspartes 2026-04-30 17:44:44 0.19 0.00 ABGESCHLOSSEN
625 Erspartes 2026-05-04 04:28:56 0.77 0.00 ABGESCHLOSSEN
626 Erspartes 2026-05-04 18:45:45 0.10 0.00 ABGESCHLOSSEN
627 Erspartes 2026-05-05 12:11:03 1.00 0.00 ABGESCHLOSSEN
628 Erspartes 2026-05-05 14:51:21 0.35 0.00 ABGESCHLOSSEN
629 Erspartes 2026-05-06 12:11:47 1.00 0.00 ABGESCHLOSSEN
630 Erspartes 2026-05-07 12:24:35 1.00 0.00 ABGESCHLOSSEN
631 Erspartes 2026-05-08 12:56:33 1.00 0.00 ABGESCHLOSSEN
632 Erspartes 2026-05-09 14:05:03 0.60 0.00 ABGESCHLOSSEN
633 Erspartes 2026-05-11 12:13:29 1.00 0.00 ABGESCHLOSSEN
634 Erspartes 2026-05-12 12:11:58 1.00 0.00 ABGESCHLOSSEN
635 Erspartes 2026-05-13 11:48:16 0.95 0.00 ABGESCHLOSSEN
636 Erspartes 2026-05-13 12:59:20 1.00 0.00 ABGESCHLOSSEN
637 Erspartes 2026-05-13 19:11:45 0.17 0.00 ABGESCHLOSSEN
638 Erspartes 2026-05-13 20:38:44 0.66 0.00 ABGESCHLOSSEN
639 Erspartes 2026-05-14 15:00:51 0.85 0.00 ABGESCHLOSSEN
640 Erspartes 2026-05-14 16:40:47 0.99 0.00 ABGESCHLOSSEN
641 Erspartes 2026-05-14 16:56:24 0.20 0.00 ABGESCHLOSSEN
642 Erspartes 2026-05-15 13:46:19 0.45 0.00 ABGESCHLOSSEN
643 Erspartes 2026-05-15 14:38:44 0.93 0.00 ABGESCHLOSSEN
644 Erspartes 2026-05-15 15:37:15 0.34 0.00 ABGESCHLOSSEN
645 Erspartes 2026-05-15 17:21:07 0.21 0.00 ABGESCHLOSSEN
646 Erspartes 2026-05-15 17:58:24 0.33 0.00 ABGESCHLOSSEN
647 Erspartes 2026-05-16 12:48:49 0.12 0.00 ABGESCHLOSSEN
648 Erspartes 2026-05-16 12:49:41 0.67 0.00 ABGESCHLOSSEN
649 Erspartes 2026-05-16 14:06:38 0.08 0.00 ABGESCHLOSSEN
650 Erspartes 2026-05-16 14:36:40 0.96 0.00 ABGESCHLOSSEN
651 Erspartes 2026-05-16 15:22:19 0.52 0.00 ABGESCHLOSSEN
652 Erspartes 2026-05-16 21:22:56 0.76 0.00 ABGESCHLOSSEN
653 Erspartes 2026-05-17 09:00:32 0.12 0.00 ABGESCHLOSSEN
654 Erspartes 2026-05-18 12:17:49 1.00 0.00 ABGESCHLOSSEN
655 Erspartes 2026-05-18 14:15:34 0.10 0.00 ABGESCHLOSSEN
656 Erspartes 2026-05-18 19:31:25 0.99 0.00 ABGESCHLOSSEN
657 Erspartes 2026-05-18 20:28:55 0.87 0.00 ABGESCHLOSSEN
658 Erspartes 2026-05-19 12:19:05 1.00 0.00 ABGESCHLOSSEN
659 Erspartes 2026-05-20 12:23:55 0.50 0.00 ABGESCHLOSSEN
660 Erspartes 2026-05-21 11:24:20 0.10 0.00 ABGESCHLOSSEN
661 Erspartes 2026-05-21 12:31:14 0.80 0.00 ABGESCHLOSSEN
662 Erspartes 2026-05-22 00:52:30 0.59 0.00 ABGESCHLOSSEN
663 Erspartes 2026-05-22 15:15:17 0.89 0.00 ABGESCHLOSSEN
664 Erspartes 2026-05-22 15:36:08 0.46 0.00 ABGESCHLOSSEN
665 Erspartes 2026-05-23 19:12:51 0.03 0.00 ABGESCHLOSSEN
666 Erspartes 2026-05-23 19:21:30 0.77 0.00 ABGESCHLOSSEN
667 Erspartes 2026-05-25 09:28:38 0.56 0.00 ABGESCHLOSSEN
668 Erspartes 2026-05-25 10:57:01 0.49 0.00 ABGESCHLOSSEN
669 Erspartes 2026-05-26 12:22:31 0.50 0.00 ABGESCHLOSSEN
670 Erspartes 2026-05-26 16:03:14 0.70 0.00 ABGESCHLOSSEN
671 Erspartes 2026-05-27 12:18:19 1.00 0.00 ABGESCHLOSSEN
672 Erspartes 2026-05-28 10:59:23 0.71 0.00 ABGESCHLOSSEN
673 Erspartes 2026-05-28 12:17:27 1.00 0.00 ABGESCHLOSSEN
674 Erspartes 2026-05-28 17:11:56 0.65 0.00 ABGESCHLOSSEN
675 Erspartes 2026-05-29 12:24:17 1.00 0.00 ABGESCHLOSSEN
676 Erspartes 2026-05-29 15:49:33 1.00 0.00 ABGESCHLOSSEN
677 Erspartes 2026-05-29 16:13:57 0.85 0.00 ABGESCHLOSSEN
678 Erspartes 2026-06-01 09:11:11 0.20 0.00 ABGESCHLOSSEN
679 Erspartes 2026-06-01 12:17:49 1.00 0.00 ABGESCHLOSSEN
680 Erspartes 2026-06-02 12:33:11 1.00 0.00 ABGESCHLOSSEN
681 Erspartes 2026-06-03 06:59:35 0.05 0.00 ABGESCHLOSSEN
682 Erspartes 2026-06-03 08:32:08 0.80 0.00 ABGESCHLOSSEN
683 Erspartes 2026-06-03 18:24:55 0.15 0.00 ABGESCHLOSSEN
684 Erspartes 2026-06-05 10:33:28 0.52 0.00 ABGESCHLOSSEN
685 Erspartes 2026-06-05 13:19:02 0.26 0.00 ABGESCHLOSSEN
686 Erspartes 2026-06-05 18:37:43 0.40 0.00 ABGESCHLOSSEN
687 Erspartes 2026-06-06 14:02:29 0.41 0.00 ABGESCHLOSSEN
688 Erspartes 2026-06-07 10:51:57 0.45 0.00 ABGESCHLOSSEN
689 Erspartes 2026-06-07 10:58:42 0.76 0.00 ABGESCHLOSSEN
690 Erspartes 2026-06-08 12:11:23 1.00 0.00 ABGESCHLOSSEN
691 Erspartes 2026-06-09 12:18:52 1.00 0.00 ABGESCHLOSSEN
692 Erspartes 2026-06-10 12:08:16 1.00 0.00 ABGESCHLOSSEN
693 Erspartes 2026-06-11 12:10:11 1.00 0.00 ABGESCHLOSSEN
694 Erspartes 2026-06-12 12:22:18 0.50 0.00 ABGESCHLOSSEN
695 Erspartes 2026-06-13 15:05:41 0.80 0.00 ABGESCHLOSSEN
696 Erspartes 2026-06-15 12:23:52 1.00 0.00 ABGESCHLOSSEN
697 Erspartes 2026-06-16 09:00:33 0.79 0.00 ABGESCHLOSSEN
698 Erspartes 2026-06-16 12:31:23 0.10 0.00 ABGESCHLOSSEN
699 Erspartes 2026-06-16 19:26:49 0.10 0.00 ABGESCHLOSSEN
700 Erspartes 2026-06-17 12:25:06 1.00 0.00 ABGESCHLOSSEN
701 Erspartes 2026-06-18 12:23:28 1.00 0.00 ABGESCHLOSSEN
702 Erspartes 2026-06-18 17:41:34 0.20 0.00 ABGESCHLOSSEN
703 Erspartes 2026-06-18 21:31:12 0.53 0.00 ABGESCHLOSSEN
704 Erspartes 2026-06-19 12:22:41 0.50 0.00 ABGESCHLOSSEN
705 Erspartes 2026-06-21 13:00:04 0.06 0.00 ABGESCHLOSSEN
706 Erspartes 2026-06-22 00:41:06 0.51 0.00 ABGESCHLOSSEN
707 Erspartes 2026-06-22 12:24:12 1.00 0.00 ABGESCHLOSSEN
708 Erspartes 2026-06-22 13:57:39 0.23 0.00 ABGESCHLOSSEN
709 Erspartes 2026-06-22 15:43:43 1.00 0.00 ABGESCHLOSSEN
710 Erspartes 2026-06-22 17:13:45 0.50 0.00 ABGESCHLOSSEN
711 Erspartes 2026-06-22 17:18:11 0.36 0.00 ABGESCHLOSSEN
712 Erspartes 2026-06-22 20:44:49 0.30 0.00 ABGESCHLOSSEN
713 Erspartes 2026-06-24 17:59:54 0.98 0.00 ABGESCHLOSSEN
714 Erspartes 2026-06-25 10:24:46 0.26 0.00 ABGESCHLOSSEN
715 Erspartes 2026-06-25 10:26:07 0.24 0.00 ABGESCHLOSSEN
716 Erspartes 2026-06-27 09:50:05 0.43 0.00 ABGESCHLOSSEN
717 Erspartes 2026-06-28 10:23:51 0.82 0.00 ABGESCHLOSSEN
718 Erspartes 2026-06-28 10:26:05 0.82 0.00 ABGESCHLOSSEN
719 Erspartes 2026-06-28 10:49:14 0.18 0.00 ABGESCHLOSSEN
720 Erspartes 2026-06-28 12:00:48 0.38 0.00 ABGESCHLOSSEN
721 Erspartes 2026-06-29 12:31:07 1.00 0.00 ABGESCHLOSSEN
722 Erspartes 2026-06-30 12:27:44 1.00 0.00 ABGESCHLOSSEN
723 Erspartes 2026-07-01 11:36:08 0.47 0.00 ABGESCHLOSSEN
724 Erspartes 2026-07-01 12:09:30 0.50 0.00 ABGESCHLOSSEN
725 Erspartes 2026-07-01 15:35:09 0.60 0.00 ABGESCHLOSSEN
726 Erspartes 2026-07-02 12:17:13 1.00 0.00 ABGESCHLOSSEN
727 Erspartes 2026-07-02 16:52:24 0.75 0.00 ABGESCHLOSSEN
728 Erspartes 2026-07-03 12:21:15 1.00 0.00 ABGESCHLOSSEN
729 Erspartes 2026-07-03 15:03:14 0.35 0.00 ABGESCHLOSSEN
730 Erspartes 2026-07-03 21:56:51 0.03 0.00 ABGESCHLOSSEN
731 Erspartes 2026-07-03 22:07:20 0.77 0.00 ABGESCHLOSSEN
732 Erspartes 2026-07-04 07:50:56 0.63 0.00 ABGESCHLOSSEN
733 Erspartes 2026-07-04 10:00:24 1.00 0.00 ABGESCHLOSSEN
734 Erspartes 2026-07-06 12:27:02 1.00 0.00 ABGESCHLOSSEN
735 Erspartes 2026-07-06 12:30:05 0.19 0.00 ABGESCHLOSSEN
736 Erspartes 2026-07-07 12:32:47 1.00 0.00 ABGESCHLOSSEN
737 Erspartes 2026-07-08 12:17:18 1.00 0.00 ABGESCHLOSSEN
738 Erspartes 2026-07-08 16:38:38 0.25 0.00 ABGESCHLOSSEN
739 Erspartes 2026-07-08 16:41:23 0.80 0.00 ABGESCHLOSSEN
740 Erspartes 2026-07-08 16:48:09 1.00 0.00 ABGESCHLOSSEN
741 Erspartes 2026-07-08 18:03:27 1.00 0.00 ABGESCHLOSSEN
742 Erspartes 2026-07-08 21:40:47 0.10 0.00 ABGESCHLOSSEN
743 Erspartes 2026-07-09 08:42:46 0.28 0.00 ABGESCHLOSSEN
744 Erspartes 2026-07-09 12:36:44 0.73 0.00 ABGESCHLOSSEN
745 Erspartes 2026-07-09 16:35:27 0.09 0.00 ABGESCHLOSSEN
746 Erspartes 2026-07-10 15:55:49 0.88 0.00 ABGESCHLOSSEN
747 Erspartes 2026-07-12 12:08:26 0.92 0.00 ABGESCHLOSSEN
748 Erspartes 2026-07-13 12:22:52 1.00 0.00 ABGESCHLOSSEN
749 Erspartes 2026-07-13 15:10:12 0.55 0.00 ABGESCHLOSSEN
750 Erspartes 2026-07-14 12:23:58 1.00 0.00 ABGESCHLOSSEN
751 Erspartes 2026-07-15 12:08:52 1.00 0.00 ABGESCHLOSSEN
752 Erspartes 2026-07-16 09:00:30 0.57 0.00 ABGESCHLOSSEN
753 Erspartes 2026-07-16 12:22:05 1.00 0.00 ABGESCHLOSSEN
754 Erspartes 2026-07-17 11:36:24 1.00 0.00 ABGESCHLOSSEN
755 Erspartes 2026-07-17 14:41:39 0.25 0.00 ABGESCHLOSSEN
756 Erspartes 2026-07-20 11:47:14 1.00 0.00 ABGESCHLOSSEN
757 Erspartes 2026-07-21 16:12:22 0.05 0.00 ABGESCHLOSSEN
758 Erspartes 2026-07-22 11:57:45 1.00 0.00 ABGESCHLOSSEN
759 Erspartes 2026-07-23 11:57:22 1.00 0.00 ABGESCHLOSSEN
760 Erspartes 2026-07-24 11:48:49 1.00 0.00 ABGESCHLOSSEN
761 Erspartes 2026-07-24 14:25:04 0.45 0.00 ABGESCHLOSSEN
762 Erspartes 2026-07-26 18:50:39 0.70 0.00 ABGESCHLOSSEN
763 Erspartes 2026-07-28 12:01:05 0.23 0.00 ABGESCHLOSSEN
764 Erspartes 2026-07-30 14:57:11 0.51 0.00 ABGESCHLOSSEN
765 Erspartes 2026-07-30 15:14:32 0.46 0.00 ABGESCHLOSSEN
766 Erspartes 2026-07-30 15:27:59 0.96 0.00 ABGESCHLOSSEN
767 Erspartes 2026-07-31 15:49:42 0.94 0.00 ABGESCHLOSSEN
768 Erspartes 2026-08-01 12:32:07 0.01 0.00 ABGESCHLOSSEN
769 Erspartes 2026-08-05 15:00:39 1.00 0.00 ABGESCHLOSSEN
770 Erspartes 2026-08-05 16:08:01 0.87 0.00 ABGESCHLOSSEN
771 Erspartes 2026-08-05 16:21:32 0.28 0.00 ABGESCHLOSSEN
772 Erspartes 2026-08-05 17:34:07 0.60 0.00 ABGESCHLOSSEN
773 Erspartes 2026-08-05 18:16:55 0.33 0.00 ABGESCHLOSSEN
774 Erspartes 2026-08-05 18:41:57 0.92 0.00 ABGESCHLOSSEN
775 Erspartes 2026-08-08 13:12:53 0.13 0.00 ABGESCHLOSSEN
776 Erspartes 2026-08-08 13:55:31 0.76 0.00 ABGESCHLOSSEN
777 Erspartes 2026-08-08 22:11:09 -142.00 0.00 ABGESCHLOSSEN
778 Erspartes 2026-08-11 15:09:15 0.89 0.00 ABGESCHLOSSEN
779 Erspartes 2026-08-11 15:14:08 0.14 0.00 ABGESCHLOSSEN
780 Erspartes 2026-08-12 09:47:17 0.91 0.00 ABGESCHLOSSEN
781 Erspartes 2026-08-12 11:30:15 0.30 0.00 ABGESCHLOSSEN
782 Erspartes 2026-08-12 16:38:55 0.09 0.00 ABGESCHLOSSEN
783 Erspartes 2026-08-15 09:00:24 0.35 0.00 ABGESCHLOSSEN
784 Erspartes 2026-08-15 15:39:11 0.93 0.00 ABGESCHLOSSEN
785 Erspartes 2026-08-15 16:06:38 0.16 0.00 ABGESCHLOSSEN
786 Erspartes 2026-08-15 16:57:03 0.71 0.00 ABGESCHLOSSEN
787 Erspartes 2026-08-15 21:51:54 0.46 0.00 ABGESCHLOSSEN
788 Erspartes 2026-08-17 12:28:01 0.94 0.00 ABGESCHLOSSEN
789 Erspartes 2026-08-17 14:39:39 0.55 0.00 ABGESCHLOSSEN
+14
View File
@@ -38,6 +38,20 @@ class Settings(BaseSettings):
# Beim Start die aktuellen EZB-Kurse nachladen (benötigt Internet). # Beim Start die aktuellen EZB-Kurse nachladen (benötigt Internet).
ecb_fetch_on_start: bool = True ecb_fetch_on_start: bool = True
# --- Oeffentliche Adresse ------------------------------------------------
# Unter welcher Adresse Certain von aussen erreichbar ist, z. B.
# https://certain.example.com – OHNE Pfad am Ende.
#
# Gebraucht fuer die Rueckleit-Adresse der SSO-Anmeldung. Die liesse sich
# zwar aus der Anfrage ableiten, aber hinter einem Reverse Proxy kaeme
# dabei die INTERNE Adresse heraus (http://10.0.0.5:8071/...), und der
# Anmeldedienst weist sie ab – mit einer Fehlermeldung, die nicht sagt,
# woran es liegt. Wer sie hier hinschreibt, hat das Problem nicht.
#
# Leer = aus der Anfrage ableiten. Das genuegt ohne Proxy und in der
# Entwicklung.
public_base_url: str = ""
# --- CORS ---------------------------------------------------------------- # --- CORS ----------------------------------------------------------------
# Leer = kein Cross-Origin (gleiche Herkunft, da FastAPI das SPA selbst # Leer = kein Cross-Origin (gleiche Herkunft, da FastAPI das SPA selbst
# ausliefert). Kommagetrennte Liste oder "*" nur bei Bedarf. # ausliefert). Kommagetrennte Liste oder "*" nur bei Bedarf.
+99 -2
View File
@@ -1,24 +1,33 @@
"""Gemeinsame FastAPI-Abhängigkeiten: aktueller Benutzer (Cookie), Admin-Pflicht. """Gemeinsame FastAPI-Abhängigkeiten: aktueller Benutzer, Admin-Pflicht.
Die Anmeldung läuft über eine server-seitige Sitzung im Cookie (siehe Die Anmeldung läuft über eine server-seitige Sitzung im Cookie (siehe
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung ``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
und liefert den Benutzer – oder 401. und liefert den Benutzer – oder 401.
Zweiter Weg für die native App: eine Gerätemarke der Reichweite ``app`` im
Header ``X-Certain-Token``. Eine App kann kein Cookie-Formular ausfüllen und
soll nicht das Passwort auf dem Gerät halten. Die enge ``quick_add``-Marke
bleibt davon unberührt – sie gilt weiterhin nur dort, wo
``require_quick_token`` sie ausdrücklich verlangt.
``enforce_account_state`` hängt am zentralen ``/api``-Router und sperrt Benutzer ``enforce_account_state`` hängt am zentralen ``/api``-Router und sperrt Benutzer
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt
ansprach, kam daran vorbei. ansprach, kam daran vorbei.
""" """
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import get_settings from .config import get_settings
from .database import get_db from .database import get_db
from .models import Role, User from .models import ApiToken, Role, TokenScope, User
from .schemas import UserOut from .schemas import UserOut
from .services import policy as policy_service from .services import policy as policy_service
from .services import sessions from .services import sessions
from .security import hash_session_token
_credentials_exc = HTTPException( _credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -51,7 +60,57 @@ def _session_user(request: Request, db: Session) -> User | None:
return request.state.session_user return request.state.session_user
token = request.cookies.get(get_settings().cookie_name, "") token = request.cookies.get(get_settings().cookie_name, "")
user = sessions.validate(db, token) user = sessions.validate(db, token)
herkunft = "cookie"
if user is None:
# Die native App meldet sich mit ihrer Marke statt mit einem Cookie.
# Hier und nicht erst in ``get_current_user``, damit auch
# ``enforce_account_state`` sie sieht – sonst käme eine App an der
# Pflicht zum Passwortwechsel vorbei.
user = _app_token_user(request, db)
herkunft = "marke"
request.state.session_user = user request.state.session_user = user
request.state.auth_via = herkunft if user is not None else None
return user
def _app_token_user(request: Request, db: Session) -> User | None:
"""Benutzer hinter einer App-Marke im Header ``X-Certain-Token``.
Nur die Reichweite ``app`` zählt hier. Eine ``quick_add``-Marke darf auf
diesem Weg NICHT zur Anmeldung werden – sonst wäre die bewusst enge Marke
still zu einem Vollzugang geworden.
"""
roh = (request.headers.get("X-Certain-Token") or "").strip()
if not roh:
return None
marke = (
db.query(ApiToken)
.filter(ApiToken.token_hash == hash_session_token(roh),
ApiToken.scope == TokenScope.app)
.first()
)
if marke is None:
return None
besitzer = db.get(User, marke.owner_id)
if besitzer is None or not besitzer.is_active:
return None
marke.last_used_at = datetime.now(timezone.utc)
db.commit()
return besitzer
def require_cookie_session(request: Request, db: Session = Depends(get_db)) -> User:
"""Wie ``get_current_user``, aber NUR mit echter Anmeldung.
Für Schritte, die eine Marke nicht auslösen darf – allen voran das
Ausstellen weiterer Marken.
"""
user = get_current_user(request, db)
if getattr(request.state, "auth_via", None) != "cookie":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Dafür ist eine richtige Anmeldung nötig, keine Gerätemarke",
)
return user return user
@@ -90,6 +149,44 @@ def enforce_account_state(request: Request, db: Session = Depends(get_db)) -> No
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=grund) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=grund)
_token_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungültige oder fehlende Gerätemarke",
)
def require_quick_token(request: Request, db: Session = Depends(get_db)) -> ApiToken:
"""Prüft die Gerätemarke im Header ``X-Certain-Token``.
Bewusst eine EIGENE Abhängigkeit statt eines Sonderfalls in
``get_current_user``: So gilt eine Marke ausschliesslich an den Endpunkten,
die sie ausdrücklich verlangen. Auf allen übrigen Wegen ist sie schlicht
keine Anmeldung – wer sie erbeutet, kommt an keine Daten.
"""
roh = (request.headers.get("X-Certain-Token") or "").strip()
if not roh:
raise _token_exc
marke = (
db.query(ApiToken)
.filter(ApiToken.token_hash == hash_session_token(roh),
ApiToken.scope == TokenScope.quick_add)
.first()
)
if marke is None:
raise _token_exc
besitzer = db.get(User, marke.owner_id)
if besitzer is None or not besitzer.is_active:
raise _token_exc
# Wer sein Passwort wechseln muss, soll auch nicht per Kurzbefehl buchen.
grund = pending_reason(db, besitzer)
if grund is not None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=grund)
marke.last_used_at = datetime.now(timezone.utc)
db.commit()
request.state.token_user = besitzer
return marke
def require_admin(current_user: User = Depends(get_current_user)) -> User: def require_admin(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != Role.admin: if current_user.role != Role.admin:
raise HTTPException( raise HTTPException(
+160 -4
View File
@@ -5,11 +5,16 @@ gebaute Frontend (``web/dist``) aus – hinter dem Nginx Proxy Manager des Nutze
Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt. Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
""" """
import logging
import threading import threading
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, FastAPI, HTTPException from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -19,8 +24,11 @@ from .config import get_settings
from .database import SessionLocal from .database import SessionLocal
from .deps import enforce_account_state from .deps import enforce_account_state
from .migrate import upgrade_to_head from .migrate import upgrade_to_head
log = logging.getLogger(__name__)
from .routers import ( from .routers import (
accounts, accounts,
bank,
auth, auth,
backup, backup,
budgets, budgets,
@@ -29,8 +37,15 @@ from .routers import (
documents, documents,
imports, imports,
policy, policy,
inbox,
mail,
oidc,
quick,
uebertraege,
rates, rates,
settings as settings_router, settings as settings_router,
subscriptions,
tags,
transactions, transactions,
users, users,
) )
@@ -42,6 +57,8 @@ settings = get_settings()
# Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier). # Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier).
REPO_ROOT = Path(__file__).resolve().parent.parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DIST_DIR = REPO_ROOT / "web" / "dist" DIST_DIR = REPO_ROOT / "web" / "dist"
# Einmal aufgeloest: Gegen diesen Pfad wird jede Anfrage geprueft.
DIST_WURZEL = DIST_DIR.resolve()
@asynccontextmanager @asynccontextmanager
@@ -54,6 +71,17 @@ async def lifespan(app: FastAPI):
finally: finally:
db.close() db.close()
# Das Sitzungs-Cookie ohne „Secure" wandert auch über eine unverschlüsselte
# Verbindung – wer mitliest, ist angemeldet. Die Vorgabe bleibt trotzdem
# aus: Sie umzustellen sperrt jeden aus, der die App im Heimnetz über HTTP
# aufruft, und ein Ausgesperrter sieht die Ursache nirgends.
if not settings.cookie_secure:
log.warning(
"APP_COOKIE_SECURE ist aus – das Sitzungs-Cookie geht auch über "
"unverschlüsselte Verbindungen. Wenn Certain über HTTPS erreichbar "
"ist (und für die Bankanbindung MUSS es das), setz es auf 1."
)
# EZB-Kurse im HINTERGRUND laden – der Start darf nie darauf warten # EZB-Kurse im HINTERGRUND laden – der Start darf nie darauf warten
# (langsames/geblocktes Netz oder hängendes DNS würde den Dienst sonst # (langsames/geblocktes Netz oder hängendes DNS würde den Dienst sonst
# blockieren und der Health-Check nie grün werden). # blockieren und der Health-Check nie grün werden).
@@ -66,7 +94,70 @@ async def lifespan(app: FastAPI):
s.close() s.close()
threading.Thread(target=_laden, name="ecb-rates", daemon=True).start() threading.Thread(target=_laden, name="ecb-rates", daemon=True).start()
# Zahlungs-Mails regelmässig abholen. Wie beim Kursabruf im Hintergrund:
# Ein langsames oder nicht erreichbares Postfach darf den Dienst nicht
# blockieren. Fehler landen sichtbar an der Quelle, nicht im Log-Nirwana.
stopp = threading.Event()
def _mails() -> None:
from datetime import datetime, timedelta, timezone
from .models import MailSource
from .services import mail_import
while not stopp.wait(60):
s = SessionLocal()
try:
jetzt = datetime.now(timezone.utc)
for quelle in s.query(MailSource).filter(MailSource.is_active.is_(True)):
faellig = quelle.last_poll_at is None or quelle.last_poll_at + timedelta(
minutes=quelle.poll_minutes
) <= jetzt
if faellig:
mail_import.abruf_mit_fehlerbehandlung(s, quelle)
except Exception:
log.exception("Mail-Abruf fehlgeschlagen")
finally:
s.close()
threading.Thread(target=_mails, name="mail-abruf", daemon=True).start()
# Bankumsätze im selben Takt-Muster. Bewusst ein EIGENER Thread: Ein
# hängender Bankzugang darf den Mail-Abruf nicht mitreissen.
#
# Anders als beim Mail-Abruf entstehen hier BUCHUNGEN. Deshalb steht der
# Takt je Verbindung auf 0, bis ihn jemand einschaltet – nichts läuft
# ungefragt los. Gegen doppelte Buchungen sichern die Kennung des Umsatzes,
# das Startdatum hinter der letzten Buchung und das Übergehen vorgemerkter
# Umsätze; siehe bank_sync.
def _bank() -> None:
from datetime import datetime, timezone
from .models import BankConnection, User
from .services import bank_sync
while not stopp.wait(60):
s = SessionLocal()
try:
jetzt = datetime.now(timezone.utc)
for verbindung in s.query(BankConnection).filter(
BankConnection.poll_minutes > 0
):
if not bank_sync.faellig(verbindung, jetzt):
continue
nutzer = s.get(User, verbindung.owner_id)
if nutzer is None:
continue
bank_sync.abruf_mit_fehlerbehandlung(s, nutzer, verbindung)
except Exception:
log.exception("Bank-Abruf fehlgeschlagen")
finally:
s.close()
threading.Thread(target=_bank, name="bank-abruf", daemon=True).start()
yield yield
stopp.set()
app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan) app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan)
@@ -86,6 +177,20 @@ if settings.cors_origins.strip():
allow_headers=["*"], allow_headers=["*"],
) )
@app.middleware("http")
async def keine_zwischenspeicherung(request, call_next):
"""API-Antworten nie zwischenspeichern.
Ohne Cache-Control darf ein Browser selbst entscheiden, und der Zurueck-
Knopf zeigt dann Salden von vorhin. Bei Geld ist eine veraltete Anzeige
schlimmer als eine langsame.
"""
antwort = await call_next(request)
if request.url.path.startswith("/api"):
antwort.headers["Cache-Control"] = "no-store"
return antwort
# --- API ------------------------------------------------------------------- # --- API -------------------------------------------------------------------
# Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt: # Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt:
# Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an # Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an
@@ -104,11 +209,13 @@ def version() -> dict:
api.include_router(auth.router) api.include_router(auth.router)
api.include_router(oidc.router)
api.include_router(users.router) api.include_router(users.router)
api.include_router(settings_router.router) api.include_router(settings_router.router)
api.include_router(policy.router) api.include_router(policy.router)
api.include_router(accounts.router) api.include_router(accounts.router)
api.include_router(categories.router) api.include_router(categories.router)
api.include_router(tags.router)
api.include_router(transactions.router) api.include_router(transactions.router)
api.include_router(budgets.router) api.include_router(budgets.router)
api.include_router(dashboard.router) api.include_router(dashboard.router)
@@ -116,6 +223,47 @@ api.include_router(imports.router)
api.include_router(documents.router) api.include_router(documents.router)
api.include_router(backup.router) api.include_router(backup.router)
api.include_router(rates.router) api.include_router(rates.router)
api.include_router(quick.router)
api.include_router(inbox.router)
api.include_router(mail.router)
api.include_router(bank.router)
api.include_router(uebertraege.router)
api.include_router(subscriptions.router)
# Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern.
# Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler
# steht sonst nur „detail" in der Antwort und die Mitteilung bleibt leer –
# man sieht dann nicht, DASS etwas schiefging, geschweige denn was.
def _ist_kurzbefehl(request: Request) -> bool:
return request.url.path.startswith("/api/quick/")
@app.exception_handler(StarletteHTTPException)
async def http_fehler(request: Request, exc: StarletteHTTPException):
inhalt = {"detail": exc.detail}
if _ist_kurzbefehl(request):
inhalt["message"] = str(exc.detail)
inhalt["ok"] = False
return JSONResponse(status_code=exc.status_code, content=inhalt,
headers=getattr(exc, "headers", None))
@app.exception_handler(RequestValidationError)
async def eingabe_fehler(request: Request, exc: RequestValidationError):
inhalt = {"detail": exc.errors()}
if _ist_kurzbefehl(request):
fehlend = [
".".join(str(t) for t in f.get("loc", ()) if t not in ("body", "query"))
for f in exc.errors()
]
inhalt["message"] = ("Die Anfrage ist unvollständig: "
+ ", ".join(f for f in fehlend if f)
+ ". Beim Datei-Kurzbefehl muss der Anfragetext "
+ "'Formular' sein, mit den Feldern file (Datei) "
+ "und account_id.")
inhalt["ok"] = False
return JSONResponse(status_code=422, content=jsonable_encoder(inhalt))
app.include_router(api) app.include_router(api)
@@ -134,7 +282,15 @@ if DIST_DIR.exists():
# /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA). # /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA).
if full_path.startswith("api"): if full_path.startswith("api"):
raise HTTPException(status_code=404, detail="Not Found") raise HTTPException(status_code=404, detail="Not Found")
candidate = DIST_DIR / full_path # Aufgeloest UND geprueft, ob die Datei noch im Auslieferungs-Ordner
if full_path and candidate.is_file(): # liegt. Ohne das liefert "/../../etc/passwd" genau das aus: Der Pfad
return FileResponse(candidate) # kommt roh aus der Adresse, und "Path / '../x'" verlaesst den Ordner
# klaglos. Ohne Anmeldung, denn diese Route liegt vor jeder Pruefung.
try:
kandidat = (DIST_DIR / full_path).resolve()
except (OSError, ValueError):
kandidat = None
if (full_path and kandidat is not None and kandidat.is_file()
and kandidat.is_relative_to(DIST_WURZEL)):
return FileResponse(kandidat)
return FileResponse(DIST_DIR / "index.html", headers={"Cache-Control": "no-cache"}) return FileResponse(DIST_DIR / "index.html", headers={"Cache-Control": "no-cache"})
+790 -4
View File
@@ -12,6 +12,7 @@ hinzu und werden dann alle über ``owner_id`` je Benutzer getrennt.
from __future__ import annotations from __future__ import annotations
import enum import enum
import json
# date/time unter Alias, weil es Spalten namens "date"/"time" gibt, die die # date/time unter Alias, weil es Spalten namens "date"/"time" gibt, die die
# gleichnamigen Typen sonst in den (späten) Annotationen beschatten. # gleichnamigen Typen sonst in den (späten) Annotationen beschatten.
from datetime import date as date_t, datetime, time as time_t, timezone from datetime import date as date_t, datetime, time as time_t, timezone
@@ -22,7 +23,9 @@ from sqlalchemy import (
DateTime, DateTime,
Enum as SAEnum, Enum as SAEnum,
ForeignKey, ForeignKey,
Index,
Integer, Integer,
LargeBinary,
Numeric, Numeric,
String, String,
Text, Text,
@@ -90,6 +93,16 @@ class AmountMode(str, enum.Enum):
signed = "signed" # eine Spalte, mit Vorzeichen (− = Ausgabe) signed = "signed" # eine Spalte, mit Vorzeichen (− = Ausgabe)
debit_credit = "debit_credit" # getrennte Soll-/Haben-Spalten debit_credit = "debit_credit" # getrennte Soll-/Haben-Spalten
abs_expense = "abs_expense" # Betrag ohne Vorzeichen, immer Ausgabe abs_expense = "abs_expense" # Betrag ohne Vorzeichen, immer Ausgabe
# Eine Spalte mit Vorzeichen, nur andersherum: + = Ausgabe.
#
# So fuehren Kreditkarten ihre Auszuege, und das ist aus ihrer Sicht
# richtig: Ein Einkauf erhoeht die Schuld, die Rechnungszahlung senkt sie.
# Gemeint ist derselbe Vorgang wie bei "signed", nur aus der anderen
# Richtung erzaehlt.
#
# abs_expense ist dafuer KEIN Ersatz – es machte auch die
# Rechnungszahlung und jede Gutschrift zur Ausgabe.
signed_inverted = "signed_inverted"
class MatchField(str, enum.Enum): class MatchField(str, enum.Enum):
@@ -113,7 +126,11 @@ class User(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False) # Leer bei reinen SSO-Nutzern: Wer sich nur ueber einen fremden
# Anmeldedienst anmeldet, hat hier nie ein Passwort gesetzt. NULL heisst
# ausdruecklich "kein Passwort-Login", nicht "leeres Passwort" – die
# Anmeldung prueft das, bevor sie ueberhaupt vergleicht.
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
role: Mapped[Role] = mapped_column(_enum(Role), nullable=False, default=Role.user) role: Mapped[Role] = mapped_column(_enum(Role), nullable=False, default=Role.user)
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True) display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
@@ -148,6 +165,72 @@ class User(Base):
) )
class UserIdentity(Base):
"""Ein Konto bei einem fremden Anmeldedienst, das zu diesem Nutzer gehoert.
Eigene Tabelle und nicht zwei Spalten am Nutzer: Jemand kann sich ueber
mehrere Dienste anmelden, und die Kennung (``subject``) gilt immer nur
beim jeweiligen Anbieter. Zwei Nutzer duerfen dieselbe Kennung haben,
solange sie von verschiedenen Anbietern kommt.
``subject`` ist die ``sub``-Angabe aus dem ID-Token: die einzige, die der
Anbieter garantiert stabil haelt. Die E-Mail steht nur zur Anzeige dabei –
sie kann sich aendern, ohne dass es ein anderer Mensch wird.
"""
__tablename__ = "user_identities"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
last_login_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
# Eine Kennung gehoert zu genau EINEM Nutzer. Ohne diese Regel koennte
# ein zweiter Nutzer dieselbe Kennung bekommen, und wer sich anmeldet,
# landete mal hier und mal dort.
UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),
)
class OidcState(Base):
"""Ein begonnener Anmeldeversuch, bis der Nutzer zurueckkommt.
Muss serverseitig liegen und nicht im Cookie: Der Nutzer verlaesst die
Seite und kommt ueber eine fremde Adresse zurueck – bei ``SameSite=lax``
kaeme ein Cookie zwar mit, aber ``code_verifier`` und ``nonce`` gehoeren
ohnehin nicht in den Browser.
Wird beim Einloesen GELOESCHT, nicht nur als benutzt markiert: Ein Code
darf genau einmal eingeloest werden.
"""
__tablename__ = "oidc_states"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
state: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
nonce: Mapped[str] = mapped_column(String(64), nullable=False)
code_verifier: Mapped[str] = mapped_column(String(128), nullable=False)
redirect_uri: Mapped[str] = mapped_column(String(500), nullable=False)
# Gesetzt, wenn ein bereits angemeldeter Nutzer sein Konto VERKNUEPFEN will
# statt sich anzumelden. Dann darf am Ende kein fremdes Konto entstehen.
link_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
# Wohin es in der Oberflaeche danach weitergeht.
weiter: Mapped[str | None] = mapped_column(String(255), nullable=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class Session(Base): class Session(Base):
"""Server-seitige Sitzung. Im Cookie liegt nur das Klartext-Token, hier der Hash.""" """Server-seitige Sitzung. Im Cookie liegt nur das Klartext-Token, hier der Hash."""
@@ -240,6 +323,51 @@ class Account(Base):
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f51b5") color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f51b5")
opening_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) opening_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
opening_date: Mapped[date_t | None] = mapped_column(Date, nullable=True) opening_date: Mapped[date_t | None] = mapped_column(Date, nullable=True)
# Was die Karte ausgeben DARF – nicht, was auf ihr liegt.
#
# Eine Kreditkarte ist kein Guthaben. Abbezahlt steht sie auf null, jede
# Ausgabe drueckt sie ins Minus, die Rechnungszahlung holt sie zurueck.
# Der Rahmen ist eine Eigenschaft des Vertrags mit der Bank und hat im
# Saldo nichts zu suchen – wer ihn dort eintraegt, sieht Geld, das er
# nicht hat.
#
# Deshalb ein eigenes Feld: Die Zahl ist es wert, aufgehoben zu werden
# („noch 1.900 von 2.500 frei"), nur eben nicht als Vermoegen.
#
# Optional, und nur bei Kreditkarten sinnvoll. Ohne Eintrag aendert sich
# nichts.
credit_limit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Wer hierher ueberweist, spart – und zwar gegen DIESE Kategorie.
#
# Sparen ist eine Entscheidung ueber ein KONTO, nicht ueber jede einzelne
# Buchung: Ein Sparkonto ist zum Sparen da, sonst waere es keins. Die
# Angabe gehoert deshalb hierher und nicht an jeden Uebertrag – einmal
# gesagt, gilt sie fuer alles, was danach kommt.
#
# Sie steht ueber der Vorgabe nach Kontotyp (siehe SAVINGS_ACCOUNT_TYPES).
# Das ist der Punkt: Ein Revolut-Pocket ist bei der Bank ein ganz normales
# Konto und kommt als „girokonto" herein. Am Typ laesst sich nicht ablesen,
# dass man darauf spart – am ausdruecklichen Eintrag hier schon.
#
# SET NULL: Verschwindet die Kategorie, faellt nur die Zuordnung weg. Ein
# Konto zu loeschen, weil man eine Kategorie aufraeumt, waere absurd.
savings_category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
# Geld, an das man nicht herankommt.
#
# Eine Saeule 3a ist gesperrt bis zur Rente, zur Selbstaendigkeit oder zur
# Auswanderung. Es als Teil dessen zu zaehlen, was man ausgeben kann, ist
# schlicht falsch – der Betrag steht da und ist doch nicht da.
#
# Es bleibt Vermoegen: Der Verlauf und das Nettovermoegen rechnen es weiter
# mit. Nur die Summe, die beantwortet „wie viel habe ich zur Verfuegung",
# laesst es aussen vor und weist es daneben aus.
#
# Deshalb ein eigenes Kennzeichen und keine Ableitung aus dem Kontotyp: Ein
# Depot ist frei verkaeuflich, eine Saeule 3a nicht, und beide kommen als
# „depot" herein. Am Typ laesst sich das nicht ablesen.
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -281,7 +409,7 @@ class CategoryGroup(Base):
name: Mapped[str] = mapped_column(String(120), nullable=False) name: Mapped[str] = mapped_column(String(120), nullable=False)
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense) kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list") icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480") color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -303,10 +431,26 @@ class Category(Base):
name: Mapped[str] = mapped_column(String(120), nullable=False) name: Mapped[str] = mapped_column(String(120), nullable=False)
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense) type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal") icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480") color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Umbuchungen zwischen eigenen Konten sind keine Ausgabe: Das Geld ist nicht
# weg, es liegt woanders. Solche Kategorien bleiben aus Budget, Dashboard und
# Auswertungen heraus – die Buchung selbst zählt aber weiter auf den Saldo.
excluded_from_stats: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Diese Ausgabe ist kein Verlust. Sparen, Rücklagen, Altersvorsorge: Das Geld
# ist ausgegeben im Sinne des Budgets – man kann es diesen Monat nicht mehr
# verbrauchen –, aber es ist nicht weg.
#
# Zwei Folgen, und die zweite ist die wichtigere:
# 1. Beträge stehen neutral statt rot. NICHT grün: Grün heisst in Certain
# überall "Geld kommt rein", und CHF 1'010 in Grün liest sich als
# Einnahme. Grau sagt genau das Richtige – kein Verlust, kein Gewinn.
# 2. Mehr als geplant ist keine Warnung. Wer 1'000 statt 800 zurücklegt,
# hat nichts überzogen; ihn deswegen rot anzuschreien ist schlicht
# falsch herum.
spending_is_good: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
group: Mapped[CategoryGroup] = relationship(back_populates="categories") group: Mapped[CategoryGroup] = relationship(back_populates="categories")
@@ -347,10 +491,25 @@ class Transaction(Base):
budget_category_id: Mapped[int | None] = mapped_column( budget_category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
) )
# Budget-Monat (YYYY-MM), wenn er NICHT der aus dem Datum abgeleitete ist.
# Leer heisst: aus dem Datum. Das ist der Normalfall; gesetzt wird das Feld
# nur fuer den Ausnahmefall, dass eine Zahlung sichtbar in den einen Monat
# faellt, aber zum anderen gehoert – die Miete, die am 24. abgeht, oder die
# Rechnung, die man verspaetet begleicht.
budget_period: Mapped[str | None] = mapped_column(String(7), nullable=True, index=True)
payee: Mapped[str | None] = mapped_column(String(255), nullable=True) payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
description: Mapped[str | None] = mapped_column(String(255), nullable=True) description: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# „Sieht gleich aus, ist aber richtig so."
#
# Drei Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche
# Einkaeufe koennen es auch sein. Wer das einmal entschieden hat, soll
# nicht bei jedem Blick in die Liste erneut gefragt werden – und schon gar
# nicht zum Loeschen gedraengt, nur damit die Meldung verschwindet.
#
# Die Buchung bleibt unveraendert; das ist nur ein Haken an der Frage.
duplicate_ok: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Import (Phase 4): Herkunft für Undo und Duplikaterkennung. # Import (Phase 4): Herkunft für Undo und Duplikaterkennung.
import_batch_id: Mapped[int | None] = mapped_column( import_batch_id: Mapped[int | None] = mapped_column(
@@ -358,9 +517,58 @@ class Transaction(Base):
) )
import_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) import_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# Die Kennung, unter der die Quelle diese Zahlung fuehrt – bei der Bank die
# des Umsatzes, mit der Quelle davor ("bank:…", "mail:…", "shortcut:…").
#
# Sie steht bewusst HIER und nicht nur am Eingangs-Eintrag. Der Eintrag ist
# eine Zwischenstation: Er kann verschwinden, weil ein Konto neu verbunden
# wurde, weil ein aelteres Backup ihn nicht kannte, weil jemand aufgeraeumt
# hat. Die Buchung bleibt. Solange die Kennung an der Buchung haengt, kann
# dieselbe Zahlung kein zweites Mal hereinkommen – und zwar unabhaengig
# davon, ob der pruefende Code davor daran gedacht hat: Die Eindeutigkeit
# weiter unten laesst es gar nicht zu.
#
# Leer bleibt sie bei allem, was keine Kennung mitbringt: von Hand erfasst,
# aus einer CSV-Datei. Dort schuetzt weiterhin nur ``import_hash``, und das
# ist eine Aehnlichkeit, keine Kennung.
#
# 320 statt 255: Die Kennung der Quelle darf selbst schon 255 lang sein,
# der Name der Quelle davor kostet ein paar Zeichen mehr. Abschneiden waere
# hier die falsche Sparsamkeit – zwei abgeschnittene Kennungen saehen
# gleich aus, und dann verschluckt eine Zahlung die andere.
source_uid: Mapped[str | None] = mapped_column(String(320), nullable=True)
# Der Vertrag, zu dem diese Buchung gehoert – Netflix, Handy, Versicherung.
#
# Ein ETIKETT, wie die Uebertrags-Verknuepfung: An der Buchung selbst
# aendert sich nichts, sie behaelt Betrag, Konto und Kategorie. Hier steht
# nur, dass die beiden zusammengehoeren. Zieht man es ab, bleibt eine
# gewoehnliche Buchung zurueck.
#
# SET NULL: Verschwindet der Vertrag, verschwindet das Etikett – nie die
# Buchung. Wer ein Abo kuendigt und loescht, will nicht seine
# Zahlungshistorie mitloeschen.
subscription_id: Mapped[int | None] = mapped_column(
ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
__table_args__ = (
# Ein eindeutiger INDEX, keine UNIQUE-Spaltenregel: Letztere zwingt
# SQLite dazu, die Tabelle neu zu bauen – und dabei reisst das
# implizite DELETE die abhaengigen Zeilen ueber ON DELETE CASCADE mit
# (Uebertrags-Verknuepfungen, abgelehnte Vorschlaege). Siehe
# tests/test_migrations.py. Der Index haelt dasselbe durch, ohne die
# Tabelle anzufassen.
#
# NULL zaehlt in SQL nicht als gleich – Buchungen ohne Kennung stehen
# sich also nicht gegenseitig im Weg. Genau das ist gewollt: Die Regel
# greift nur dort, wo es wirklich eine Kennung gibt.
Index("uq_txn_owner_source_uid", "owner_id", "source_uid", unique=True),
)
class ExchangeRate(Base): class ExchangeRate(Base):
"""EZB-Referenzkurs: wie viele Einheiten der Währung ein Euro wert war. """EZB-Referenzkurs: wie viele Einheiten der Währung ein Euro wert war.
@@ -395,6 +603,19 @@ class BudgetTemplateLine(Base):
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
) )
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Wie der Soll-Betrag zustande kommt.
#
# "fest" – die Zahl in ``planned_cents``, wie eh und je.
# "berechnet" – Summe der Vertraege dieser Kategorie PLUS ``zuschlag_cents``.
#
# Der Zuschlag ist der Punkt: Wer fuer Streaming 80 plant, weil 58 fest
# gebunden sind und 22 Luft sein sollen, will beim naechsten Preisaufschlag
# nicht nachrechnen muessen – und seine 22 auch nicht verlieren.
#
# ``planned_cents`` bleibt bei "berechnet" stehen, wird aber nicht gelesen.
# So findet man seinen alten Wert wieder, wenn man zurueckschaltet.
modus: Mapped[str] = mapped_column(String(16), nullable=False, default="fest")
zuschlag_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
__table_args__ = ( __table_args__ = (
UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"), UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"),
@@ -456,6 +677,10 @@ class BudgetPeriodLine(Base):
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
) )
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Wie in der Vorlage – der angepasste Monat erbt die Betriebsart, sonst
# friert er beim Anpassen heimlich auf einer Zahl ein.
modus: Mapped[str] = mapped_column(String(16), nullable=False, default="fest")
zuschlag_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
period: Mapped[BudgetPeriod] = relationship(back_populates="lines") period: Mapped[BudgetPeriod] = relationship(back_populates="lines")
@@ -504,6 +729,12 @@ class ImportTemplate(Base):
# JSON: Feld -> 0-basierter Spaltenindex (oder null). Felder: date, time, # JSON: Feld -> 0-basierter Spaltenindex (oder null). Felder: date, time,
# amount, debit, credit, currency, payee, purpose, counterparty_iban. # amount, debit, credit, currency, payee, purpose, counterparty_iban.
column_map: Mapped[str] = mapped_column(Text, nullable=False, default="{}") column_map: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# Zeilenfilter: nur Zeilen übernehmen, in denen Spalte `filter_column` den
# Wert `filter_value` hat. Revolut legt alle Pockets („Giro", „Erspartes" …)
# mit je eigenem Saldo-Verlauf in EINE Datei – ohne Filter landete alles auf
# demselben Konto und zählte doppelt.
filter_column: Mapped[int | None] = mapped_column(Integer, nullable=True)
filter_value: Mapped[str | None] = mapped_column(String(120), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -520,6 +751,10 @@ class ImportBatch(Base):
template_id: Mapped[int | None] = mapped_column( template_id: Mapped[int | None] = mapped_column(
ForeignKey("import_templates.id", ondelete="SET NULL"), nullable=True ForeignKey("import_templates.id", ondelete="SET NULL"), nullable=True
) )
# Name der Bank-Vorlage (Raiffeisen, N26 …). Eigene Vorlagen stehen in
# template_id; die fest eingebauten haben keine ID und wuerden sonst
# vergessen, sobald der Import durch ist.
preset: Mapped[str | None] = mapped_column(String(64), nullable=True)
filename: Mapped[str] = mapped_column(String(255), nullable=False, default="") filename: Mapped[str] = mapped_column(String(255), nullable=False, default="")
row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
imported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) imported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -551,6 +786,15 @@ class CategorizationRule(Base):
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Wenn True: passende Buchung wird zum Transfer (category_id bleibt leer). # Wenn True: passende Buchung wird zum Transfer (category_id bleibt leer).
is_transfer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_transfer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Optionales festes Zielkonto für Transfer-Regeln. Ohne -> Zielkonto wird aus
# dem Buchungstext (Konto-Erkennungs-Texte) erraten.
target_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
# Art der Regel: "expense" | "income" | "transfer". Erlaubt, dasselbe Wort je
# einmal für eine Ausgabe- UND eine Einnahme-Kategorie zu verwenden (das
# Matching filtert ohnehin nach Buchungsart).
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="expense")
source: Mapped[RuleSource] = mapped_column(_enum(RuleSource), nullable=False, default=RuleSource.learned) source: Mapped[RuleSource] = mapped_column(_enum(RuleSource), nullable=False, default=RuleSource.learned)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0) priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -561,5 +805,547 @@ class CategorizationRule(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = ( __table_args__ = (
UniqueConstraint("owner_id", "match_field", "pattern", name="uq_rule_owner_field_pattern"), # Der Betrag gehört dazu: dasselbe Wort darf mehrere Preise haben
# („sv = 5.00" und „sv = 7.50"), ohne Betrag ist es der Auffang.
UniqueConstraint("owner_id", "match_field", "pattern", "kind", "amount_cents",
name="uq_rule_owner_field_pattern_kind_amount"),
)
class TokenScope(str, enum.Enum):
"""Was eine Gerätemarke darf.
``quick_add`` ist die enge Form und die Voreinstellung. Eine solche Marke
liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt. Sie darf
deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist kein
Datenabfluss möglich.
``app`` ist die Marke der nativen App und gilt wie eine Anmeldung – eine
App muss alles können, was die Weboberfläche kann, und die API kennt
sonst nur das Sitzungs-Cookie. Der Klartext liegt allein im Schlüsselbund
des Geräts; widerrufen wird die Marke in den Einstellungen. Vergeben darf
sie nur eine echte Anmeldung, nie eine Marke selbst: Sonst hätte ein Dieb
sich beliebig viele Ersatzschlüssel ausgestellt und das Widerrufen wäre
wirkungslos.
"""
quick_add = "quick_add"
app = "app"
class ApiToken(Base):
"""Zugangsmerkmal für Geräte, die sich nicht anmelden können (Kurzbefehle)."""
__tablename__ = "api_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(80), nullable=False)
# Nur der Hash wird abgelegt; den Klartext sieht man einmal beim Anlegen.
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
scope: Mapped[TokenScope] = mapped_column(
_enum(TokenScope), nullable=False, default=TokenScope.quick_add
)
# Vorbelegtes Konto, damit der Kurzbefehl ohne Kontoauswahl auskommt.
default_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class InboxSource(str, enum.Enum):
shortcut = "shortcut" # per Kurzbefehl vom Telefon erfasst
mail = "mail" # aus einer Benachrichtigungs-Mail gelesen
bank = "bank" # direkt von der Bank geholt (PSD2)
transfer = "transfer" # Gegenseite eines Kontouebertrags, von Certain angelegt
class InboxStatus(str, enum.Enum):
booked = "booked" # als Buchung übernommen
pending = "pending" # wartet auf Bestätigung
unclear = "unclear" # Erkennung unsicher, erst ansehen
discarded = "discarded" # keine Zahlung / verworfen
class InboxItem(Base):
"""Eine von aussen erfasste Zahlung, bevor der Kontoauszug sie bestätigt.
Der Kontoauszug kommt erst am Monatsende. Wer eine Zahlung gleich erfasst,
weiss noch, wofür sie war – auf dem Auszug steht später oft nur ein
nichtssagender Betreibername. Der Eintrag bleibt nach dem Buchen erhalten,
damit der spätere CSV-Import ihn wiedererkennt und die Zahlung nicht ein
zweites Mal verbucht.
"""
__tablename__ = "inbox_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
source: Mapped[InboxSource] = mapped_column(_enum(InboxSource), nullable=False)
status: Mapped[InboxStatus] = mapped_column(
_enum(InboxStatus), nullable=False, default=InboxStatus.pending
)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True, index=True
)
date: Mapped[date_t] = mapped_column(Date, nullable=False, index=True)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
# Die erzeugte Buchung. SET NULL, damit das Löschen einer Buchung den
# Eingangs-Eintrag nicht mitreisst – sonst käme die Zahlung beim nächsten
# Auszug unbemerkt ein zweites Mal herein.
transaction_id: Mapped[int | None] = mapped_column(
ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True
)
# Was die Bank ueber die Umrechnung mitteilt. Ihr eigener Kurs schlaegt die
# EZB-Naeherung – und nur mit dem exakten Gegenbetrag laesst sich die
# spaeter gelieferte Gegenseite eines Uebertrags wiedererkennen.
fx_rate: Mapped[float | None] = mapped_column(Numeric(18, 8), nullable=True)
counter_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
counter_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
# Kennung der Quelle (Message-ID der Mail, Kennung des Kurzbefehls):
# schützt davor, dieselbe Meldung zweimal zu erfassen.
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
__table_args__ = (
UniqueConstraint("owner_id", "source", "external_id", name="uq_inbox_owner_source_external"),
)
class MailSource(Base):
"""Ein Postfach, aus dem Certain Zahlungs-Mails abholt."""
__tablename__ = "mail_sources"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False, default=993)
# "ssl" = TLS ab der ersten Zeile (Port 993, der Normalfall),
# "starttls" = im Klartext verbinden und dann verschlüsseln (Port 143),
# "none" = unverschlüsselt (nur sinnvoll innerhalb desselben Rechners).
security: Mapped[str] = mapped_column(String(16), nullable=False, default="ssl")
username: Mapped[str] = mapped_column(String(255), nullable=False)
# Verschlüsselt, nicht gehasht: Certain muss es dem Mailserver vorlegen.
password_enc: Mapped[str] = mapped_column(Text, nullable=False)
folder: Mapped[str] = mapped_column(String(255), nullable=False, default="INBOX")
# Nach dem Auswerten: dorthin verschieben (leer = nicht verschieben).
done_folder: Mapped[str | None] = mapped_column(String(255), nullable=True)
mark_seen: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
poll_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class MailSender(Base):
"""Welcher Absender auf welches Konto bucht.
Ohne Zuordnung weiss Certain nicht, wohin eine erkannte Zahlung gehört –
dieselbe Mailadresse kann je nach Konto etwas anderes bedeuten.
"""
__tablename__ = "mail_senders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("mail_sources.id", ondelete="CASCADE"), nullable=False, index=True
)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
# Teilstrings, gegen Absender und Betreff geprüft. Leer = jede Mail.
from_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
subject_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Nur Zahlungen dieser Währung. Leer = jede. Revolut schickt für EUR und CHF
# dieselbe Mail vom selben Absender – erst die Währung sagt, welches Konto
# gemeint ist.
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class PendingUpload(Base):
"""Eine vom Telefon abgelieferte Datei, die noch niemand geprüft hat.
Am Telefon lässt sich ein Auszug nicht sinnvoll durchsehen – hunderte
Zeilen mit Kategorie-Auswahl sind auf dem Bildschirm keine Erleichterung.
Die Datei wird deshalb nur entgegengenommen; geprüft und gebucht wird
später in der normalen Import-Ansicht, mit Saldo-Abgleich und allem.
"""
__tablename__ = "pending_uploads"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
account_id: Mapped[int] = mapped_column(
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
filename: Mapped[str] = mapped_column(String(255), nullable=False, default="")
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class TransferLink(Base):
"""Zwei Buchungen, die dieselbe Bewegung sind – ein Übertrag zwischen
eigenen Konten.
Bewusst eine SCHICHT OBENDRAUF: An den Buchungen selbst wird nichts
geändert. Sie behalten Betrag, Konto, Kategorie, Import-Schlüssel und
Stapel-Zugehörigkeit. Hier steht nur, dass sie zusammengehören.
Der frühere Ansatz führte beide zu einer Übertrags-Buchung zusammen und
löschte die Originale. Daran hing eine Kette von Folgeproblemen – der
Duplikatschutz verlor einen der beiden Import-Schlüssel, die Rücknahme
eines Imports riss die Gegenseite mit, und eine falsche Paarung liess sich
ohne vollständige Kopie nicht zurückholen. Nichts zu löschen lässt all das
gar nicht erst entstehen: Eine falsche Verknüpfung ist ein Etikett, das man
abzieht.
"""
__tablename__ = "transfer_links"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# CASCADE ist hier richtig: Verschwindet eine Buchung, ist die Verknüpfung
# gegenstandslos. Es verschwindet das Etikett, nie eine Buchung.
from_txn_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
to_txn_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
automatisch: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("from_txn_id", name="uq_link_from"),
UniqueConstraint("to_txn_id", name="uq_link_to"),
)
class LinkIgnored(Base):
"""Ein abgelehnter Vorschlag. Damit er nicht bei jedem Suchlauf wiederkehrt."""
__tablename__ = "link_ignored"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
a_txn_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
b_txn_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("a_txn_id", "b_txn_id", name="uq_ignored_pair"),
)
class Subscription(Base):
"""Ein Abo oder Vertrag: wiederkehrende Zahlung mit bekanntem Betrag.
Netflix, Handyvertrag, Krankenkasse, Halbtax – dieselbe Form. Deshalb
heisst der Bereich "Abos & Vertraege": Ein Handyvertrag ist kein Abo, und
niemand soll ueberlegen muessen, wo die Versicherung hingehoert.
Der Monatswert wird NICHT gespeichert, sondern gerechnet
(``amount_cents / interval_months``). Gespeichert veraltete er beim
naechsten Preisaufschlag stillschweigend.
"""
__tablename__ = "subscriptions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
# Wie bei Konten und Kategorien: Am Symbol findet man die Zeile schneller
# wieder als am Namen, wenn dreissig davon untereinander stehen.
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="refresh-ccw")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Eigene Waehrung, nicht die des Kontos: Ein deutscher Handyvertrag laeuft
# in Euro, auch wenn er von einem Franken-Konto bezahlt wird.
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
# 1 = monatlich, 3 = vierteljaehrlich, 6 = halbjaehrlich, 12 = jaehrlich.
# Als Zahl und nicht als Aufzaehlung: Der Monatswert ist dann eine Division
# statt einer Tabelle, und ein zweimonatlicher Vertrag braucht keine
# Migration.
interval_months: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# Womit bezahlt wird. Manche Abos laufen ueber die Kreditkarte, andere per
# eBill direkt vom Konto – ohne diese Angabe findet man den Abbucher nicht.
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
# Naechste Verlaengerung/Abbuchung.
next_due: Mapped[date_t | None] = mapped_column(Date, nullable=True)
# Woran die Abbuchung im Kontoauszug zu erkennen ist. Aus dem Namen
# vorbelegt, aenderbar – auf dem Auszug steht selten "Netflix".
payee_pattern: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Nur bei laengerem Intervall: das Konto, auf das monatlich zurueckgelegt
# wird. Die Jahresrechnung wird dann daraus bezahlt, statt einen Monat zu
# sprengen.
savings_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Gekuendigtes wird nicht geloescht: Der Verlauf ist die Antwort auf
# "was habe ich dafuer insgesamt bezahlt".
cancelled_at: Mapped[date_t | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
@property
def monatlich_cents(self) -> int:
"""Was der Vertrag im Monat kostet – in SEINER Waehrung.
Kaufmaennisch gerundet. Die Summe ueber viele Vertraege weicht dadurch
um ein paar Rappen von der exakten ab; das ist der Preis dafuer, dass
jede einzelne Zeile eine Zahl zeigt, die man nachrechnen kann.
"""
n = self.interval_months or 1
return round(self.amount_cents / n)
class SubscriptionLogo(Base):
"""Das Markenlogo eines Vertrags.
Eigene Tabelle und nicht eine Spalte an ``subscriptions``: Sonst zoege
jede Listenabfrage saemtliche Bilder mit aus der Datenbank, obwohl die
Liste sie einzeln nachlaedt. Ein Blob in einer Zeile, die man oft und
vollstaendig liest, ist teuer.
Gespeichert wird immer PNG, klein gerechnet – hochgeladen wird, was das
Telefon hergibt, und das sind schnell drei Megabyte fuer ein Bildchen von
dreissig Pixeln Kantenlaenge.
"""
__tablename__ = "subscription_logos"
subscription_id: Mapped[int] = mapped_column(
ForeignKey("subscriptions.id", ondelete="CASCADE"), primary_key=True
)
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
class BankApp(Base):
"""Die bei Enable Banking registrierte Anwendung dieser Installation.
Es gibt keinen API-Schlüssel zum Eintippen: Enable Banking gibt eine
Application ID und eine private Schlüsseldatei aus, mit der jede Anfrage
einzeln signiert wird (JWT, RS256). Der Schlüssel liegt deshalb hier –
verschlüsselt wie die IMAP-Passwörter, denn er ist genauso schützenswert.
Wer Certain selbst hostet, registriert eine eigene Anwendung; mitliefern
lässt sich das nicht, weil der Schlüssel an die Registrierung gebunden ist.
"""
__tablename__ = "bank_apps"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
application_id: Mapped[str] = mapped_column(String(64), nullable=False)
private_key_enc: Mapped[str] = mapped_column(Text, nullable=False)
# Öffentliche Adresse dieser Installation, an die die Bank zurückleitet.
# Muss HTTPS sein und im Control Panel hinterlegt werden. Bewusst
# eingetragen statt geraten: Hinter einem Proxy sieht der Dienst „http",
# obwohl der Browser HTTPS spricht.
base_url: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class BankConnection(Base):
"""Eine autorisierte Verbindung zu einer Bank – das Ergebnis von /sessions."""
__tablename__ = "bank_connections"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
aspsp_name: Mapped[str] = mapped_column(String(120), nullable=False)
aspsp_country: Mapped[str] = mapped_column(String(2), nullable=False)
session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Der Zeitpunkt, ab dem neu autorisiert werden muss (bis zu 180 Tage).
# Sichtbar anzeigen: Läuft er ab, hört der Abruf sonst stumm auf.
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Solange die Autorisierung läuft, steht hier der state-Wert aus /auth.
pending_state: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Takt des automatischen Abrufs in Minuten, 0 = aus. Wie beim Postfach –
# nur ist die Vorgabe hier bewusst aus, denn ein Abruf bucht.
poll_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class BankAccountLink(Base):
"""Ein Konto bei der Bank, zugeordnet zu einem Konto in Certain.
Ohne Zuordnung wird nichts abgerufen – dieselbe Regel wie bei den
Mail-Absendern: Certain rät nicht, wohin eine Bewegung gehört.
"""
__tablename__ = "bank_account_links"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
connection_id: Mapped[int] = mapped_column(
ForeignKey("bank_connections.id", ondelete="CASCADE"), nullable=False, index=True
)
# Kennung des Kontos bei Enable Banking; damit werden Salden und Umsätze geholt.
remote_uid: Mapped[str] = mapped_column(String(64), nullable=False)
iban: Mapped[str | None] = mapped_column(String(64), nullable=True)
remote_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Produktbezeichnung der Bank. Bei Revolut unterscheidet sie das Konto von
# seinen Pockets – die haben weder eigene IBAN noch eigenen Namen.
product: Mapped[str | None] = mapped_column(String(120), nullable=True)
# Kennung fuer Konten ohne IBAN (PSD2 fuehrt sie unter ``other``).
other_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
# Der uebermittelte Datensatz, wie er kam. Damit laesst sich nachsehen,
# statt zu raten, wenn zwei Konten gleich aussehen.
raw_json: Mapped[str | None] = mapped_column(Text, nullable=True)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
# Saldo, wie ihn die Bank fuehrt. Er ersetzt Certains eigene Rechnung
# nicht, er prueft sie – weichen beide ab, fehlt oder doppelt eine Buchung.
balance_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
balance_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
balance_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
# Wann DIESES Konto zuletzt abgerufen wurde. An der Verbindung waere der
# Stempel falsch, sobald man ein einzelnes Konto abruft: Er schoebe das
# Startdatum der uebrigen mit vor, und deren Umsaetze kaemen nie an.
last_sync_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("owner_id", "remote_uid", name="uq_bank_link_remote"),
)
@property
def bank_felder(self) -> dict:
"""Der uebermittelte Datensatz als Feld-Liste.
Wird in den Einstellungen aufklappbar gezeigt. Wenn zwei Konten gleich
aussehen, steht der Unterschied hier – nachsehen schlaegt raten.
"""
if not self.raw_json:
return {}
try:
daten = json.loads(self.raw_json)
except ValueError:
return {}
return daten if isinstance(daten, dict) else {}
class Tag(Base):
"""Ein frei vergebenes Etikett fuer Buchungen, mit eigener Farbe.
Neben der Kategorie, nicht statt ihr: Die Kategorie beantwortet „wofuer war
das Geld", ein Tag alles andere, was sich quer dazu legt – „Urlaub Island",
„steuerlich absetzbar", „zurueckfordern". Deshalb mehrere je Buchung und
keine Pflicht, genau eines zu waehlen.
Die Farbe traegt der Tag selbst, wie bei Kategorie und Gruppe: In einer
langen Liste erkennt man sie am Fleck, bevor man den Text liest.
"""
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(60), nullable=False)
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
)
class TransactionTag(Base):
"""Welcher Tag haengt an welcher Buchung.
Eine eigene Tabelle und kein ``secondary``-Verweis, wie ueberall hier
(``TransferLink``, ``LinkIgnored``): Sie traegt ihre eigene ``owner_id`` und
ist damit vom Backup und von der Mandanten-Pruefung erfasst wie jede andere.
CASCADE auf beiden Seiten. Verschwindet die Buchung oder der Tag, ist die
Zuordnung gegenstandslos – es verschwindet immer nur das Etikett, nie eine
Buchung.
"""
__tablename__ = "transaction_tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
transaction_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
) )
+108
View File
@@ -0,0 +1,108 @@
"""Welche OpenID-Connect-Anbieter eingerichtet sind.
Bewusst aus Umgebungsvariablen und nicht aus der Datenbank: Client-ID und
-Secret gehoeren zur Betriebsumgebung, nicht in ein Backup, und wer die
Anwendung umzieht, soll sie neu setzen muessen.
Ein zweiter Anbieter ist reine Konfiguration – kein Code aendert sich:
APP_OIDC_PROVIDERS=authentik,google
APP_OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example/application/o/certain/.well-known/openid-configuration
APP_OIDC_AUTHENTIK_CLIENT_ID=...
APP_OIDC_AUTHENTIK_CLIENT_SECRET=...
APP_OIDC_AUTHENTIK_LABEL=Authentik
Der Schluessel in ``APP_OIDC_PROVIDERS`` ist zugleich der Name in der Adresse
(``/api/auth/oidc/authentik/start``) und in der Datenbank – er darf sich
spaeter nicht aendern, sonst finden bestehende Verknuepfungen ihren Anbieter
nicht mehr wieder.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from functools import lru_cache
# Nur was sich gefahrlos in eine URL und eine Datenbankspalte schreiben laesst.
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$")
# Ohne diese drei Scopes fehlt entweder das ID-Token oder die E-Mail, ueber die
# ein bestehendes Konto gefunden wird.
STANDARD_SCOPES = "openid profile email"
@dataclass(frozen=True)
class Provider:
name: str # "authentik" – Schluessel in URL und Datenbank
label: str # "Mit Authentik anmelden" – was auf dem Knopf steht
discovery_url: str
client_id: str
client_secret: str
scopes: str = STANDARD_SCOPES
# Darf jemand, den es in Certain noch nicht gibt, ein Konto bekommen?
#
# Vorgabe NEIN, und das ist Absicht: Wer schon ein Konto hat und sich zum
# ersten Mal ueber den Anbieter anmeldet, bekaeme sonst ein zweites, leeres
# – seine Daten laegen weiter unter dem alten. Abgewiesen zu werden mit dem
# Hinweis "erst anmelden, dann verknuepfen" ist die hilfreichere Antwort.
#
# Auf true stellen, wenn sich neue Leute selbst anmelden koennen sollen.
# Dann entscheidet der Anbieter, wer in Certain darf.
auto_create: bool = False
# Wenn gesetzt: nur E-Mails dieser Domaenen duerfen neu anlegen.
allowed_domains: tuple[str, ...] = field(default_factory=tuple)
def _env(name: str, provider: str, standard: str = "") -> str:
return os.environ.get(f"APP_OIDC_{provider.upper()}_{name}", standard).strip()
def _bool(wert: str, standard: bool) -> bool:
if not wert:
return standard
return wert.lower() in ("1", "true", "yes", "ja", "on")
@lru_cache
def providers() -> dict[str, Provider]:
"""Die eingerichteten Anbieter, nach Namen.
Unvollstaendig konfigurierte werden ausgelassen und NICHT halb angeboten:
Ein Knopf, der ins Leere fuehrt, ist schlimmer als keiner.
"""
roh = os.environ.get("APP_OIDC_PROVIDERS", "")
gefunden: dict[str, Provider] = {}
for name in (n.strip().lower() for n in roh.split(",")):
if not name or not NAME_MUSTER.match(name):
continue
discovery = _env("DISCOVERY_URL", name)
client_id = _env("CLIENT_ID", name)
if not discovery or not client_id:
continue
domains = tuple(
d.strip().lower().lstrip("@")
for d in _env("ALLOWED_DOMAINS", name).split(",")
if d.strip()
)
gefunden[name] = Provider(
name=name,
label=_env("LABEL", name) or name.capitalize(),
discovery_url=discovery,
client_id=client_id,
client_secret=_env("CLIENT_SECRET", name),
scopes=_env("SCOPES", name) or STANDARD_SCOPES,
auto_create=_bool(_env("AUTO_CREATE", name), False),
allowed_domains=domains,
)
return gefunden
def get(name: str) -> Provider | None:
return providers().get((name or "").lower())
def zuruecksetzen() -> None:
"""Den Zwischenspeicher leeren – fuer Tests, die die Umgebung aendern."""
providers.cache_clear()
+244 -19
View File
@@ -2,14 +2,24 @@
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, get_owned from ..deps import get_current_user, get_owned
from ..models import Account, AccountIdentifier, IdentifierKind, User from ..models import (
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate Account,
from ..services import balances AccountIdentifier,
BankAccountLink,
Category,
CategoryKind,
IdentifierKind,
Transaction,
TxnType,
User,
)
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOrder, AccountOut, AccountUpdate
from ..services import balances, bank_sync, currency
from ..services.csv_import import normalize_text from ..services.csv_import import normalize_text
from .settings import BASE_CURRENCY_KEY, get_user_setting from .settings import BASE_CURRENCY_KEY, get_user_setting
@@ -29,7 +39,25 @@ def base_currency(db: Session, user: User) -> str:
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR" return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
def to_out(db: Session, acc: Account, base: str, on: date) -> AccountOut: def _bank_saldi(db: Session, user: User) -> dict:
"""Bank-Saldo je Konto, einmal nachgeschlagen.
Als Sammelabfrage und nicht je Konto: Die Kontenliste wird auf jeder Seite
geladen, eine Abfrage pro Zeile summierte sich schnell.
"""
return {
v.account_id: v
for v in db.query(BankAccountLink).filter(
BankAccountLink.owner_id == user.id,
BankAccountLink.account_id.isnot(None),
BankAccountLink.balance_cents.isnot(None),
)
}
def to_out(db: Session, acc: Account, base: str, on: date,
bank_saldi: dict | None = None) -> AccountOut:
bank = (bank_saldi or {}).get(acc.id)
return AccountOut( return AccountOut(
id=acc.id, id=acc.id,
name=acc.name, name=acc.name,
@@ -39,29 +67,49 @@ def to_out(db: Session, acc: Account, base: str, on: date) -> AccountOut:
color=acc.color, color=acc.color,
opening_balance_cents=acc.opening_balance_cents, opening_balance_cents=acc.opening_balance_cents,
opening_date=acc.opening_date, opening_date=acc.opening_date,
credit_limit_cents=acc.credit_limit_cents,
is_locked=acc.is_locked,
is_archived=acc.is_archived, is_archived=acc.is_archived,
position=acc.position, position=acc.position,
savings_category_id=acc.savings_category_id,
identifiers=[ identifiers=[
AccountIdentifierOut(id=i.id, kind=i.kind, value=i.value) for i in acc.identifiers AccountIdentifierOut(id=i.id, kind=i.kind, value=i.value) for i in acc.identifiers
], ],
balance_cents=balances.account_balance_cents(db, acc), balance_cents=balances.account_balance_cents(db, acc),
balance_base_cents=balances.account_balance_base_cents(db, acc, base, on), balance_base_cents=balances.account_balance_base_cents(db, acc, base, on),
bank_balance_cents=bank.balance_cents if bank else None,
bank_balance_at=bank.balance_at if bank else None,
bank_balance_vergleichbar=(
bank_sync.saldo_vergleichbar(bank.balance_type) if bank else False),
) )
def _set_identifiers(acc: Account, items) -> None: def _set_identifiers(acc: Account, items) -> None:
"""Ersetzt die Identifikator-Liste (dedupliziert nach Art + Normalform).""" """Gleicht die Identifikator-Liste auf den Ziel-Zustand ab (dedupliziert nach
acc.identifiers.clear() Art + Normalform). Bewusst als Diff (unveränderte behalten, nur fehlende
seen: set[tuple[str, str]] = set() hinzufügen / überzählige entfernen): ein einfaches clear()+neu-Anlegen würde
beim Speichern zuerst einfügen und dann löschen und so die UNIQUE-Bedingung
(account_id, kind, value_norm) verletzen (Fehler beim Umbenennen)."""
desired: dict[tuple[str, str], tuple] = {}
for it in items: for it in items:
norm = normalize_identifier(it.value, it.kind) norm = normalize_identifier(it.value, it.kind)
key = (it.kind.value, norm) if not norm:
if not norm or key in seen:
continue continue
seen.add(key) key = (it.kind.value, norm)
acc.identifiers.append( desired.setdefault(key, (it.kind, it.value.strip(), norm))
AccountIdentifier(kind=it.kind, value=it.value.strip(), value_norm=norm)
) keep: set[tuple[str, str]] = set()
for existing in list(acc.identifiers):
key = (existing.kind.value, existing.value_norm)
if key in desired:
keep.add(key)
existing.value = desired[key][1] # Original-Schreibweise ggf. angleichen
else:
acc.identifiers.remove(existing)
for key, (kind, value, norm) in desired.items():
if key not in keep:
acc.identifiers.append(AccountIdentifier(kind=kind, value=value, value_norm=norm))
@router.get("", response_model=list[AccountOut]) @router.get("", response_model=list[AccountOut])
@@ -74,13 +122,33 @@ def list_accounts(db: Session = Depends(get_db), user: User = Depends(get_curren
.order_by(Account.position, Account.name) .order_by(Account.position, Account.name)
.all() .all()
) )
return [to_out(db, a, base, today) for a in accounts] saldi = _bank_saldi(db, user)
return [to_out(db, a, base, today, saldi) for a in accounts]
def _pruefe_sparziel(db: Session, user: User, category_id: int | None) -> None:
"""Die Spar-Kategorie muss dem Nutzer gehoeren und eine AUSGABE sein.
Sparen zaehlt im Budget wie eine Ausgabe – gegen eine Einnahme-Kategorie
liesse es sich nicht verplanen, und die Zahl landete in einer Spalte, in
der sie niemand sucht.
"""
if category_id is None:
return
kategorie = get_owned(db, Category, category_id, user)
if kategorie.type != CategoryKind.expense:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(f"'{kategorie.name}' ist keine Ausgabe-Kategorie. Sparen "
f"zaehlt im Budget wie eine Ausgabe."),
)
@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED)
def create_account( def create_account(
payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
): ):
_pruefe_sparziel(db, user, payload.savings_category_id)
max_pos = ( max_pos = (
db.query(Account.position) db.query(Account.position)
.filter(Account.owner_id == user.id) .filter(Account.owner_id == user.id)
@@ -96,13 +164,17 @@ def create_account(
color=payload.color, color=payload.color,
opening_balance_cents=payload.opening_balance_cents, opening_balance_cents=payload.opening_balance_cents,
opening_date=payload.opening_date, opening_date=payload.opening_date,
credit_limit_cents=payload.credit_limit_cents,
is_locked=payload.is_locked,
savings_category_id=payload.savings_category_id,
position=(max_pos[0] + 1) if max_pos else 0, position=(max_pos[0] + 1) if max_pos else 0,
) )
_set_identifiers(acc, payload.identifiers) _set_identifiers(acc, payload.identifiers)
db.add(acc) db.add(acc)
db.commit() db.commit()
db.refresh(acc) db.refresh(acc)
return to_out(db, acc, base_currency(db, user), date.today()) return to_out(db, acc, base_currency(db, user), date.today(),
_bank_saldi(db, user))
@router.get("/{account_id}", response_model=AccountOut) @router.get("/{account_id}", response_model=AccountOut)
@@ -110,7 +182,8 @@ def get_account(
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
): ):
acc = get_owned(db, Account, account_id, user) acc = get_owned(db, Account, account_id, user)
return to_out(db, acc, base_currency(db, user), date.today()) return to_out(db, acc, base_currency(db, user), date.today(),
_bank_saldi(db, user))
@router.patch("/{account_id}", response_model=AccountOut) @router.patch("/{account_id}", response_model=AccountOut)
@@ -123,14 +196,165 @@ def update_account(
acc = get_owned(db, Account, account_id, user) acc = get_owned(db, Account, account_id, user)
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
identifiers = data.pop("identifiers", None) identifiers = data.pop("identifiers", None)
if "savings_category_id" in data:
_pruefe_sparziel(db, user, data["savings_category_id"])
for key, value in data.items(): for key, value in data.items():
if value is not None or key in ("opening_date",): # "savings_category_id" gehoert in die Ausnahme: Ohne sie liesse sich
# ein einmal gesetztes Sparziel nie wieder loesen. Fuer den
# Kreditrahmen gilt dasselbe – wer ein Konto vom Typ Kreditkarte auf
# Girokonto umstellt, muss den Rahmen wieder loswerden koennen.
if value is not None or key in (
"opening_date", "savings_category_id", "credit_limit_cents"
):
setattr(acc, key, value) setattr(acc, key, value)
if identifiers is not None: if identifiers is not None:
_set_identifiers(acc, payload.identifiers) _set_identifiers(acc, payload.identifiers)
db.commit() db.commit()
db.refresh(acc) db.refresh(acc)
return to_out(db, acc, base_currency(db, user), date.today()) return to_out(db, acc, base_currency(db, user), date.today(),
_bank_saldi(db, user))
@router.put("/order", status_code=status.HTTP_204_NO_CONTENT)
def set_account_order(
payload: AccountOrder, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
"""Reihenfolge der Konten setzen (Liste der IDs in der gewünschten Folge).
Muss vor ``/{account_id}`` stehen, sonst fängt die ID-Route den Pfad ab.
"""
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
fremd = [i for i in payload.ids if i not in konten]
if fremd:
raise HTTPException(status_code=404, detail=f"Unbekanntes Konto: {fremd[0]}")
for platz, konto_id in enumerate(payload.ids):
konten[konto_id].position = platz
db.commit()
def _entkoppele_uebertraege(db: Session, acc: Account) -> None:
"""Überträge lösen, bevor das Konto verschwindet.
Beide Konto-Spalten einer Buchung hängen an ON DELETE CASCADE. Beim Löschen
eines Kontos verschwand deshalb auch die Gegenseite eines Übertrags – und
damit Geld, das ein ANDERES Konto verlassen hat. Dessen Saldo stieg
stillschweigend. Solche Überträge werden hier zu gewöhnlichen Ausgaben bzw.
Einnahmen auf dem verbleibenden Konto; nur was allein am gelöschten Konto
hängt, geht mit ihm.
"""
# Gelöschtes Konto ist das Ziel: Der Abgang bleibt Ausgabe beim Quellkonto.
for t in (
db.query(Transaction)
.filter(Transaction.to_account_id == acc.id, Transaction.account_id != acc.id)
.all()
):
t.type = TxnType.expense
t.to_account_id = None
t.to_amount_cents = None
# Gelöschtes Konto ist die Quelle: Der Zugang bleibt Einnahme beim Zielkonto.
for t in (
db.query(Transaction)
.filter(
Transaction.account_id == acc.id,
Transaction.to_account_id.isnot(None),
Transaction.to_account_id != acc.id,
)
.all()
):
ziel = db.get(Account, t.to_account_id)
betrag = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
t.type = TxnType.income
t.account_id = t.to_account_id
t.amount_cents = betrag
t.to_account_id = None
t.to_amount_cents = None
if ziel is not None:
t.amount_eur_cents = currency.compute_amount_eur_cents(
db, betrag, ziel.currency, t.date
)
db.flush()
@router.get("/{account_id}/aufschluesselung")
def balance_breakdown(account_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Woraus sich der Saldo zusammensetzt – samt aller Übertrags-Beine.
Stimmt ein Kontostand nicht, liegt es fast immer an einer dieser Zahlen
oder an einem Übertrag, der zu viel oder zu wenig verschiebt. Ohne diese
Ansicht sucht man in einer langen Buchungsliste; mit ihr sieht man sofort,
welcher Teil nicht passt.
"""
konto = get_owned(db, Account, account_id, user)
teile = balances.aufschluesselung(db, konto)
namen = {a.id: a.name for a in db.query(Account).filter(Account.owner_id == user.id)}
# Die Übertrags-Beine einzeln: Sie sind der Teil, den man in der Liste am
# schwersten überblickt, und die haeufigste Ursache.
beine = [
{"id": t.id, "datum": t.date.isoformat(),
"von": namen.get(t.account_id, "?"), "nach": namen.get(t.to_account_id, "?"),
"raus": t.account_id == konto.id,
"cents": (t.amount_cents if t.account_id == konto.id
else (t.to_amount_cents or t.amount_cents)),
"text": t.payee or t.description}
for t in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.transfer,
(Transaction.account_id == konto.id) | (Transaction.to_account_id == konto.id),
).order_by(Transaction.date.desc(), Transaction.id.desc()).limit(50)
]
verknuepfung = (
db.query(BankAccountLink)
.filter(BankAccountLink.owner_id == user.id,
BankAccountLink.account_id == konto.id)
.first()
)
return {
"waehrung": konto.currency,
"teile": teile,
"uebertraege": beine,
"bank": {
"cents": verknuepfung.balance_cents if verknuepfung else None,
"art": verknuepfung.balance_type if verknuepfung else None,
"vergleichbar": (bank_sync.saldo_vergleichbar(verknuepfung.balance_type)
if verknuepfung else False),
},
}
@router.post("/{account_id}/saldo-uebernehmen", response_model=AccountOut)
def adopt_bank_balance(account_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Den von der Bank gemeldeten Saldo als richtig übernehmen.
Die Differenz wandert in den ANFANGSBESTAND, nicht in eine Korrektur-
buchung. Sie steht für Bewegungen, die Certain nie gesehen hat – und
genau dafür ist der Anfangsbestand da. Eine erfundene Buchung stünde
dagegen für immer in der Liste und liefe durch jede Auswertung.
Die Buchungen selbst bleiben unangetastet.
"""
konto = get_owned(db, Account, account_id, user)
verknuepfung = (
db.query(BankAccountLink)
.filter(BankAccountLink.owner_id == user.id,
BankAccountLink.account_id == konto.id,
BankAccountLink.balance_cents.isnot(None))
.first()
)
if verknuepfung is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=("Für dieses Konto liegt kein Saldo der Bank vor. Erst "
"abrufen, dann übernehmen."),
)
unterschied = verknuepfung.balance_cents - balances.account_balance_cents(db, konto)
konto.opening_balance_cents += unterschied
db.commit()
db.refresh(konto)
return to_out(db, konto, base_currency(db, user), date.today(), _bank_saldi(db, user))
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -138,5 +362,6 @@ def delete_account(
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
): ):
acc = get_owned(db, Account, account_id, user) acc = get_owned(db, Account, account_id, user)
_entkoppele_uebertraege(db, acc)
db.delete(acc) db.delete(acc)
db.commit() db.commit()
+32 -9
View File
@@ -55,7 +55,11 @@ def login(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> LoginOut: ) -> LoginOut:
user = db.query(User).filter(User.username == form.username).first() user = db.query(User).filter(User.username == form.username).first()
if user is None or not verify_password(form.password, user.password_hash): # Ein reines SSO-Konto hat kein Passwort. Der Vergleich wird gar nicht erst
# versucht – argon2 bekaeme sonst None und wuerfe einen Fehler, aus dem
# herauszulesen waere, dass es diesen Benutzernamen gibt.
ohne_passwort = user is not None and user.password_hash is None
if user is None or ohne_passwort or not verify_password(form.password, user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Benutzername oder Passwort falsch", detail="Benutzername oder Passwort falsch",
@@ -88,6 +92,25 @@ def login(
return LoginOut(status="ok", user=user_out(db, user)) return LoginOut(status="ok", user=user_out(db, user))
def _bestaetige_mit_passwort(user: User, passwort: str) -> None:
"""Die eigene Identitaet mit dem Passwort bestaetigen.
Ein reines SSO-Konto hat keines – dort ist diese Bestaetigung nicht
"falsch", sondern gar nicht moeglich. Ohne diese Unterscheidung bekaeme
argon2 None und der Nutzer eine Fehlermeldung, die nichts erklaert.
"""
if user.password_hash is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=("Dieses Konto meldet sich ueber einen Anmeldedienst an und "
"hat kein Passwort."),
)
if not verify_password(passwort, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> None: def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> None:
token = request.cookies.get(get_settings().cookie_name, "") token = request.cookies.get(get_settings().cookie_name, "")
@@ -111,6 +134,12 @@ def change_password(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> UserOut: ) -> UserOut:
if current_user.password_hash is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=("Dieses Konto meldet sich ueber einen Anmeldedienst an und "
"hat kein Passwort."),
)
if not verify_password(payload.current_password, current_user.password_hash): if not verify_password(payload.current_password, current_user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -247,10 +276,7 @@ def new_recovery_codes(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Zwei-Faktor ist nicht aktiv" status_code=status.HTTP_400_BAD_REQUEST, detail="Zwei-Faktor ist nicht aktiv"
) )
if not verify_password(payload.password, current_user.password_hash): _bestaetige_mit_passwort(current_user, payload.password)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
)
return RecoveryCodesOut(codes=twofactor.generate_recovery_codes(db, current_user)) return RecoveryCodesOut(codes=twofactor.generate_recovery_codes(db, current_user))
@@ -260,10 +286,7 @@ def disable_two_factor(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> UserOut: ) -> UserOut:
if not verify_password(payload.password, current_user.password_hash): _bestaetige_mit_passwort(current_user, payload.password)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort ist falsch"
)
if policy_service.load(db).mfa_required: if policy_service.load(db).mfa_required:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
+41 -10
View File
@@ -4,6 +4,7 @@ das wiederherstellbare Voll-Backup als JSON (siehe ``services/backup_data``)."""
import csv import csv
import io import io
import os import os
import re
import sqlite3 import sqlite3
import tempfile import tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -38,7 +39,27 @@ MAX_BACKUP_BYTES = 64 * 1024 * 1024
def _ts() -> str: def _ts() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") """Ortszeit als ``YYYY-MM-DD_HHMM`` – Dateinamen sollen lesbar sein.
Bewusst nicht UTC: wer abends um 20 Uhr exportiert, soll im Downloads-Ordner
keine Datei von 18 Uhr finden."""
return datetime.now().astimezone().strftime("%Y-%m-%d_%H%M")
def _dateiname(art: str, endung: str) -> str:
"""Einheitlicher, sprechender Dateiname für alle Downloads.
``art`` muss erkennen lassen, worum es geht – im Downloads-Ordner liegen die
Dateien sonst nebeneinander und man sieht ihnen nicht an, welche sich
zurückladen lässt (``vollbackup``) und welche nur zum Ansehen taugt
(``datenuebersicht``)."""
return f"certain-finance_{art}_{_ts()}.{endung}"
def _namensteil(text: str) -> str:
"""Beliebigen Text auf etwas eindampfen, das in einem Dateinamen taugt."""
sauber = re.sub(r"[^A-Za-z0-9]+", "-", text).strip("-").lower()
return sauber[:32] or "unbekannt"
def _sqlite_path() -> str | None: def _sqlite_path() -> str | None:
@@ -94,7 +115,7 @@ def backup_db(_: User = Depends(require_admin)):
return FileResponse( return FileResponse(
tmp.name, tmp.name,
media_type="application/octet-stream", media_type="application/octet-stream",
filename=f"certain-finance-{_ts()}.db", filename=_dateiname("datenbank", "db"),
background=BackgroundTask(os.remove, tmp.name), background=BackgroundTask(os.remove, tmp.name),
) )
@@ -136,7 +157,7 @@ def export_transactions_csv(db: Session = Depends(get_db), user: User = Depends(
return Response( return Response(
content=buf.getvalue().encode("utf-8-sig"), content=buf.getvalue().encode("utf-8-sig"),
media_type="text/csv", media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.csv"'}, headers={"Content-Disposition": f'attachment; filename="{_dateiname("buchungen", "csv")}"'},
) )
@@ -165,7 +186,7 @@ def export_transactions_json(db: Session = Depends(get_db), user: User = Depends
return Response( return Response(
content=_json_bytes(data), content=_json_bytes(data),
media_type="application/json", media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.json"'}, headers={"Content-Disposition": f'attachment; filename="{_dateiname("buchungen", "json")}"'},
) )
@@ -229,32 +250,42 @@ def export_all_json(db: Session = Depends(get_db), user: User = Depends(get_curr
return Response( return Response(
content=_json_bytes(data), content=_json_bytes(data),
media_type="application/json", media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="certain-finance-export-{_ts()}.json"'}, # „datenuebersicht", nicht „backup": diese Datei lässt sich bewusst
# nicht zurückladen (siehe services/backup_data.parse).
headers={
"Content-Disposition": f'attachment; filename="{_dateiname("datenuebersicht", "json")}"'
},
) )
# --- Voll-Backup (wiederherstellbar) --------------------------------------- # --- Voll-Backup (wiederherstellbar) ---------------------------------------
def _download(obj: dict, name: str) -> Response: def _download(obj: dict, dateiname: str) -> Response:
return Response( return Response(
content=backup_data.to_bytes(obj), content=backup_data.to_bytes(obj),
media_type="application/json", media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{name}-{_ts()}.json"'}, headers={"Content-Disposition": f'attachment; filename="{dateiname}"'},
) )
@router.get("/export/backup.json") @router.get("/export/backup.json")
def export_backup(db: Session = Depends(get_db), user: User = Depends(get_current_user)): def export_backup(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Alle eigenen Daten – vollständig und wieder einspielbar.""" """Alle eigenen Daten – vollständig und wieder einspielbar.
return _download(backup_data.export_user(db, user), "certain-finance-backup")
Der Benutzername steht im Dateinamen: bei mehreren Konten liegen sonst
gleichnamige Backups im selben Ordner."""
return _download(
backup_data.export_user(db, user),
_dateiname(f"vollbackup_{_namensteil(user.username)}", "json"),
)
@router.get("/export/system-backup.json") @router.get("/export/system-backup.json")
def export_system_backup(db: Session = Depends(get_db), _: User = Depends(require_admin)): def export_system_backup(db: Session = Depends(get_db), _: User = Depends(require_admin)):
"""Die ganze Instanz inklusive Benutzerkonten. Enthält Passwort-Hashes und """Die ganze Instanz inklusive Benutzerkonten. Enthält Passwort-Hashes und
TOTP-Secrets – die Datei ist wie ein Passwort zu behandeln.""" TOTP-Secrets – die Datei ist wie ein Passwort zu behandeln."""
return _download(backup_data.export_system(db), "certain-finance-system-backup") return _download(backup_data.export_system(db), _dateiname("vollbackup-system", "json"))
async def _read_upload(file: UploadFile) -> bytes: async def _read_upload(file: UploadFile) -> bytes:
+266
View File
@@ -0,0 +1,266 @@
"""Bankkonten über PSD2 verbinden und abrufen."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Account, BankAccountLink, BankApp, BankConnection, User
from ..schemas import (
BankAccountLinkOut,
BankAppIn,
BankAppOut,
BankConnectionOut,
BankConnectStart,
)
from ..services import bank_sync, geheimnis
from ..services.bank_api import BankFehler
log = logging.getLogger(__name__)
router = APIRouter(prefix="/bank", tags=["bank"])
def _fehler(e: BankFehler) -> HTTPException:
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# --- Die Anwendung dieser Installation ---------------------------------------
@router.get("/app", response_model=BankAppOut | None)
def get_app(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(BankApp).filter(BankApp.owner_id == user.id).first()
@router.put("/app", response_model=BankAppOut)
def set_app(payload: BankAppIn, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Application ID, Schlüsseldatei und Rückleitungs-Adresse hinterlegen."""
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
if app is None:
app = BankApp(owner_id=user.id)
db.add(app)
app.application_id = payload.application_id.strip()
app.private_key_enc = geheimnis.verschluesseln(payload.private_key)
app.base_url = payload.base_url
db.commit()
db.refresh(app)
return app
@router.delete("/app", status_code=status.HTTP_204_NO_CONTENT)
def delete_app(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
if app is not None:
db.delete(app)
db.commit()
@router.get("/banks")
def list_banks(country: str | None = None, search: str | None = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Banken suchen – in einem Land oder über alle hinweg.
Ohne ``country`` wird überall gesucht. Das braucht es, weil die IBAN nicht
verrät, wo eine Bank sitzt: Revolut vergibt deutsche IBANs aus Litauen.
"""
if not country and not search:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bitte ein Land wählen oder einen Suchbegriff eingeben.",
)
try:
return bank_sync.banken(db, user, country, search)
except BankFehler as e:
raise _fehler(e) from e
# --- Verbindungen ------------------------------------------------------------
@router.get("/connections", response_model=list[BankConnectionOut])
def list_connections(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
verbindungen = (
db.query(BankConnection)
.filter(BankConnection.owner_id == user.id, BankConnection.session_id.isnot(None))
.order_by(BankConnection.id)
.all()
)
out = []
for v in verbindungen:
eintrag = BankConnectionOut.model_validate(v)
eintrag.expires_soon = bank_sync.laeuft_bald_ab(v)
out.append(eintrag)
return out
@router.post("/connect")
def connect(payload: BankConnectStart, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Autorisierung starten. Die Antwort enthält die Adresse der Bank."""
try:
return bank_sync.starte_verbindung(
db, user, payload.aspsp_name, payload.country, payload.psu_type)
except BankFehler as e:
raise _fehler(e) from e
@router.post("/callback")
def callback(state: str, code: str, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Der Code von der Rückleitung wird zur Sitzung samt Kontoliste."""
try:
return bank_sync.schliesse_verbindung(db, user, state, code)
except BankFehler as e:
raise _fehler(e) from e
@router.post("/connections/{connection_id}/sync")
def sync(connection_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
verbindung = get_owned(db, BankConnection, connection_id, user)
try:
return bank_sync.hole_umsaetze(db, user, verbindung)
except BankFehler as e:
verbindung.last_error = str(e)[:500]
db.commit()
raise _fehler(e) from e
@router.post("/links/{link_id}/sync")
def sync_link(link_id: int, tage: int | None = None, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Nur die Umsätze DIESES Kontos holen.
Ein Abruf bucht. Wer ein einzelnes Konto abruft, will nicht, dass nebenbei
auf einem zweiten Buchungen entstehen.
"""
verknuepfung = get_owned(db, BankAccountLink, link_id, user)
verbindung = get_owned(db, BankConnection, verknuepfung.connection_id, user)
try:
if tage is not None and not 1 <= tage <= 365:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Der Zeitraum muss zwischen 1 und 365 Tagen liegen.")
return bank_sync.hole_umsaetze(db, user, verbindung,
nur_link_id=verknuepfung.id, tage=tage)
except BankFehler as e:
verbindung.last_error = str(e)[:500]
db.commit()
raise _fehler(e) from e
@router.put("/connections/{connection_id}/takt")
def set_poll(connection_id: int, minutes: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Takt des automatischen Abrufs. 0 schaltet ihn ab.
Untergrenze sechs Stunden: PSD2 verpflichtet die Banken zu VIER
unbeaufsichtigten Abfragen je Konto und Tag. Häufiger dürfen sie ablehnen,
und sie tun es – N26 antwortet dann mit „Too many requests", und der Abruf
scheitert, bis das Zeitfenster weiterrückt. Von Hand abrufen darf man
weiterhin so oft man will: Dabei sitzt der Nutzer davor.
"""
verbindung = get_owned(db, BankConnection, connection_id, user)
if minutes != 0 and not bank_sync.MIN_TAKT_MINUTEN <= minutes <= 10080:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(f"Der Takt muss 0 (aus) oder mindestens "
f"{bank_sync.MIN_TAKT_MINUTEN} Minuten sein. Die Banken lassen "
f"nur vier unbeaufsichtigte Abrufe je Konto und Tag zu."),
)
verbindung.poll_minutes = minutes
db.commit()
return {"poll_minutes": verbindung.poll_minutes}
@router.post("/connections/{connection_id}/salden")
def refresh_balances(connection_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Nur die Saldi auffrischen, ohne Umsätze zu holen.
Eigener Endpunkt, weil das auch vor jeder Zuordnung nützt: Bei mehreren
Pockets ist der Betrag oft das Einzige, woran man sie unterscheidet.
"""
verbindung = get_owned(db, BankConnection, connection_id, user)
try:
return {"saldi": bank_sync.hole_salden(db, user, verbindung)}
except BankFehler as e:
verbindung.last_error = str(e)[:500]
db.commit()
raise _fehler(e) from e
@router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_connection(connection_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
db.delete(get_owned(db, BankConnection, connection_id, user))
db.commit()
# --- Kontozuordnung ----------------------------------------------------------
@router.get("/links", response_model=list[BankAccountLinkOut])
def list_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return [
BankAccountLinkOut.model_validate(v).model_copy(update={
"balance_vergleichbar": bank_sync.saldo_vergleichbar(v.balance_type)})
for v in (
db.query(BankAccountLink)
.filter(BankAccountLink.owner_id == user.id)
.order_by(BankAccountLink.id)
.all()
)]
@router.post("/links/{link_id}/account/{account_id}", response_model=BankAccountLinkOut)
def set_link_account(link_id: int, account_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Ein Bankkonto einem Konto in Certain zuordnen.
Ohne Zuordnung wird nichts abgerufen – Certain rät nicht, wohin fremde
Umsätze gehören.
"""
verknuepfung = get_owned(db, BankAccountLink, link_id, user)
konto = get_owned(db, Account, account_id, user)
if konto.currency.upper() != verknuepfung.currency.upper():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(f"Währungen passen nicht: Das Bankkonto führt "
f"{verknuepfung.currency}, das Konto '{konto.name}' führt "
f"{konto.currency}."),
)
# Ein Konto darf nur von EINEM Bankkonto gefuellt werden. Sonst holen zwei
# Zugaenge dieselben Umsaetze – mit verschiedenen Kennungen, also ohne dass
# der Duplikatschutz greift. Das passiert schnell, wenn dieselbe Bank
# zweimal verbunden ist: Enable Banking fuehrt sie je Land getrennt.
geloest = (
db.query(BankAccountLink)
.filter(BankAccountLink.owner_id == user.id,
BankAccountLink.account_id == konto.id,
BankAccountLink.id != verknuepfung.id)
.all()
)
for andere in geloest:
andere.account_id = None
verknuepfung.account_id = konto.id
db.commit()
db.refresh(verknuepfung)
if geloest:
log.info("Zuordnung von %s auf %s uebernommen, %d andere geloest",
verknuepfung.remote_uid, konto.name, len(geloest))
return verknuepfung
@router.delete("/links/{link_id}/account", response_model=BankAccountLinkOut)
def clear_link_account(link_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
verknuepfung = get_owned(db, BankAccountLink, link_id, user)
verknuepfung.account_id = None
db.commit()
db.refresh(verknuepfung)
return verknuepfung
+107 -18
View File
@@ -15,7 +15,7 @@ from ..models import (
User, User,
) )
from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate
from ..services import budget from ..services import abos, budget
from .accounts import base_currency from .accounts import base_currency
from .settings import BUDGET_START_DAY_KEY, get_user_setting from .settings import BUDGET_START_DAY_KEY, get_user_setting
@@ -23,10 +23,7 @@ router = APIRouter(prefix="/budget", tags=["budget"])
def _start_day(db: Session, user: User) -> int: def _start_day(db: Session, user: User) -> int:
try: return budget.start_tag(db, user)
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
except ValueError:
return 1
def _require_expense_category(db: Session, user: User, category_id: int) -> Category: def _require_expense_category(db: Session, user: User, category_id: int) -> Category:
@@ -49,36 +46,68 @@ def get_template(db: Session = Depends(get_db), user: User = Depends(get_current
.all() .all()
) )
return { return {
"lines": [{"category_id": ln.category_id, "planned_cents": ln.planned_cents} for ln in lines], # Betriebsart und Zuschlag gehören dazu: PUT gibt sie zurück und die
# Monatsansicht liefert sie ohnehin. Ohne sie zeigt ein Client eine
# berechnete Zeile als feste an und schreibt sie beim nächsten
# Speichern auch als feste zurück.
"lines": [
{
"category_id": ln.category_id,
"planned_cents": ln.planned_cents,
"modus": ln.modus,
"zuschlag_cents": ln.zuschlag_cents,
}
for ln in lines
],
"income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income], "income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income],
} }
MODI = ("fest", "berechnet")
def _pruefe_modus(modus: str | None) -> None:
if modus is not None and modus not in MODI:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unbekannte Betriebsart '{modus}'. Moeglich: {', '.join(MODI)}.",
)
@router.put("/template/category/{category_id}") @router.put("/template/category/{category_id}")
def set_template_category( def set_template_category(
category_id: int, category_id: int,
planned_cents: int, planned_cents: int | None = None,
modus: str | None = None,
zuschlag_cents: int | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> dict: ) -> dict:
_require_expense_category(db, user, category_id) _require_expense_category(db, user, category_id)
_pruefe_modus(modus)
line = ( line = (
db.query(BudgetTemplateLine) db.query(BudgetTemplateLine)
.filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id) .filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id)
.first() .first()
) )
if planned_cents <= 0: ziel_modus = modus or (line.modus if line is not None else "fest")
# Eine Zeile ohne Inhalt wird geloescht – sie ist dasselbe wie "kein
# Budget". Bei "berechnet" gilt das NICHT: Dort ist die Zeile selbst die
# Aussage ("rechne aus den Vertraegen"), auch wenn der Zuschlag null ist.
leer = ziel_modus == "fest" and (planned_cents is not None and planned_cents <= 0)
if leer:
if line is not None: if line is not None:
db.delete(line) db.delete(line)
db.commit() db.commit()
return {"category_id": category_id, "planned_cents": 0} return {"category_id": category_id, "planned_cents": 0, "modus": "fest",
"zuschlag_cents": 0}
if line is None: if line is None:
line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=planned_cents) line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=0)
db.add(line) db.add(line)
else: budget.uebernimm(line, planned_cents, modus, zuschlag_cents)
line.planned_cents = planned_cents
db.commit() db.commit()
return {"category_id": category_id, "planned_cents": planned_cents} return {"category_id": category_id, "planned_cents": line.planned_cents,
"modus": line.modus, "zuschlag_cents": line.zuschlag_cents}
# --- Geplante Einnahmen ---------------------------------------------------- # --- Geplante Einnahmen ----------------------------------------------------
@@ -149,8 +178,12 @@ def _build_view(db: Session, user: User, label: str) -> dict:
start_day = _start_day(db, user) start_day = _start_day(db, user)
start, end = budget.period_bounds(label, start_day) start, end = budget.period_bounds(label, start_day)
planned = budget.effective_planned(db, user, label) planned = budget.effective_planned(db, user, label)
actual = budget.actual_expense_by_category(db, user, start, end, base) actual = budget.actual_expense_by_category(db, user, label, start, end, base)
income_actual = budget.income_by_category(db, user, start, end, base) income_actual = budget.income_by_category(db, user, label, start, end, base)
# Was aus Vertraegen kommt – auch fuer Kategorien auf "fest". Dort ist es
# kein Rechenwert, sondern ein Hinweis: "hier sind 43.89 gebunden".
vertraege = abos.uebersicht(db, user, base)
zeilen = {ln.category_id: ln for ln in budget._zeilen(db, user, label)}
groups_out = [] groups_out = []
total_planned = total_actual = 0 total_planned = total_actual = 0
@@ -170,15 +203,37 @@ def _build_view(db: Session, user: User, label: str) -> dict:
a = actual.get(c.id, 0) a = actual.get(c.id, 0)
g_planned += p g_planned += p
g_actual += a g_actual += a
zeile = zeilen.get(c.id)
cats.append({ cats.append({
"category_id": c.id, "name": c.name, "icon": c.icon, "color": c.color, "category_id": c.id, "name": c.name, "icon": c.icon, "color": c.color,
"planned_cents": p, "actual_cents": a, "delta_cents": p - a, "over": a > p, "planned_cents": p, "actual_cents": a, "delta_cents": p - a, "over": a > p,
# Woraus sich der Soll-Betrag ergibt. "aus_vertraegen_cents"
# steht auch bei "fest" – dann als Hinweis, nicht als Summand.
"modus": zeile.modus if zeile is not None else "fest",
"zuschlag_cents": zeile.zuschlag_cents if zeile is not None else 0,
"aus_vertraegen_cents": vertraege["je_kategorie"].get(c.id, 0),
"vertraege_anzahl": vertraege["anzahl"].get(c.id, 0),
# "over" bleibt die nackte Tatsache; ob sie eine Warnung IST,
# entscheidet die Kategorie. Wer mehr zuruecklegt als geplant,
# hat nichts ueberzogen.
"good": c.spending_is_good,
}) })
total_planned += g_planned total_planned += g_planned
total_actual += g_actual total_actual += g_actual
# Ueberschritten ist eine Gruppe an dem, was WIRKLICH zu viel war. Das
# Sparen bleibt draussen: Sonst faerbt sich "Finanzen" rot, weil jemand
# mehr zurueckgelegt hat als geplant – eine Warnung fuer etwas Gutes,
# und daneben stuende die echte Ueberschreitung ununterscheidbar.
# Angezeigt werden weiter die vollen Summen; nur das Urteil laesst das
# Sparen aus.
echt_geplant = sum(c["planned_cents"] for c in cats if not c["good"])
echt_ist = sum(c["actual_cents"] for c in cats if not c["good"])
groups_out.append({ groups_out.append({
"id": g.id, "name": g.name, "icon": g.icon, "color": g.color, "id": g.id, "name": g.name, "icon": g.icon, "color": g.color,
"planned_cents": g_planned, "actual_cents": g_actual, "over": g_actual > g_planned, "planned_cents": g_planned, "actual_cents": g_actual,
"over": echt_ist > echt_geplant,
"good": bool(cats) and all(c["good"] for c in cats),
"aus_vertraegen_cents": sum(c["aus_vertraegen_cents"] for c in cats),
"categories": cats, "categories": cats,
}) })
@@ -210,6 +265,13 @@ def _build_view(db: Session, user: User, label: str) -> dict:
"next": budget.shift_label(label, 1), "next": budget.shift_label(label, 1),
"groups": groups_out, "groups": groups_out,
"income": income_out, "income": income_out,
# Was sich NICHT zuordnen liess. Ohne diese zwei Angaben fehlt das Geld
# im Budget, ohne dass irgendwo steht, dass es fehlt.
"vertraege": {
"ohne_kategorie_cents": vertraege["ohne_kategorie_cents"],
"ohne_kategorie_anzahl": vertraege["ohne_kategorie_anzahl"],
"ohne_kurs": vertraege["ohne_kurs"],
},
"totals": { "totals": {
"planned_expense": total_planned, "planned_expense": total_planned,
"actual_expense": total_actual, "actual_expense": total_actual,
@@ -237,13 +299,17 @@ def view(label: str, db: Session = Depends(get_db), user: User = Depends(get_cur
def set_month_category( def set_month_category(
label: str, label: str,
category_id: int, category_id: int,
planned_cents: int, planned_cents: int | None = None,
modus: str | None = None,
zuschlag_cents: int | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> dict: ) -> dict:
budget.parse_label(label) budget.parse_label(label)
_require_expense_category(db, user, category_id) _require_expense_category(db, user, category_id)
budget.set_month_category(db, user, label, category_id, max(0, planned_cents)) _pruefe_modus(modus)
budget.set_month_category(db, user, label, category_id, planned_cents, modus,
zuschlag_cents)
return _build_view(db, user, label) return _build_view(db, user, label)
@@ -254,3 +320,26 @@ def reset_month(
budget.parse_label(label) budget.parse_label(label)
budget.reset_month(db, user, label) budget.reset_month(db, user, label)
return _build_view(db, user, label) return _build_view(db, user, label)
# --- Kopieren Monat <-> Vorlage --------------------------------------------
@router.post("/template/from-month/{label}")
def template_from_month(
label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> dict:
"""„Diesen Monat als Standard festlegen": Monat -> Vorlage."""
budget.parse_label(label)
budget.copy_month_to_template(db, user, label)
return get_template(db, user)
@router.post("/{label}/from-template")
def month_from_template(
label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> dict:
"""„Standard in diesen Monat übernehmen": Vorlage -> Monat (überschreibt)."""
budget.parse_label(label)
budget.copy_template_to_month(db, user, label)
return _build_view(db, user, label)
+100 -20
View File
@@ -4,11 +4,12 @@ Eine Kategorie erbt ihre Art (Einnahme/Ausgabe) von ihrer Gruppe.
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, get_owned from ..deps import get_current_user, get_owned
from ..models import CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User from ..models import Account, CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User
from ..schemas import ( from ..schemas import (
CategoryCreate, CategoryCreate,
CategoryGroupCreate, CategoryGroupCreate,
@@ -33,8 +34,14 @@ FLAG_MIN_APPLIED = 5
FLAG_RATIO_DEN = 3 # >= 1/3 (jede dritte) FLAG_RATIO_DEN = 3 # >= 1/3 (jede dritte)
def group_out(g: CategoryGroup) -> CategoryGroupOut: def group_out(g: CategoryGroup, keyword_counts: dict[int, int] | None = None) -> CategoryGroupOut:
cats = sorted(g.categories, key=lambda c: (c.position, c.name.lower())) cats = sorted(g.categories, key=lambda c: (c.position, c.name.lower()))
out = []
for c in cats:
eintrag = CategoryOut.model_validate(c)
if keyword_counts is not None:
eintrag.keyword_count = keyword_counts.get(c.id, 0)
out.append(eintrag)
return CategoryGroupOut( return CategoryGroupOut(
id=g.id, id=g.id,
name=g.name, name=g.name,
@@ -43,10 +50,29 @@ def group_out(g: CategoryGroup) -> CategoryGroupOut:
color=g.color, color=g.color,
position=g.position, position=g.position,
is_builtin=g.is_builtin, is_builtin=g.is_builtin,
categories=[CategoryOut.model_validate(c) for c in cats], categories=out,
) )
def _keyword_counts(db: Session, user: User) -> dict[int, int]:
"""Schlagwörter je Kategorie – eine Abfrage für die ganze Liste.
Die Übersicht markiert damit die Kategorien, für die schon etwas hinterlegt
ist; ein Abruf je Kategorie waere dafuer unverhaeltnismaessig."""
zeilen = (
db.query(CategorizationRule.category_id, func.count(CategorizationRule.id))
.filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.source == RuleSource.manual,
CategorizationRule.is_transfer.is_(False),
CategorizationRule.category_id.isnot(None),
)
.group_by(CategorizationRule.category_id)
.all()
)
return {cat_id: anzahl for cat_id, anzahl in zeilen}
def _next_position(db: Session, model, **filters) -> int: def _next_position(db: Session, model, **filters) -> int:
q = db.query(model.position) q = db.query(model.position)
for k, v in filters.items(): for k, v in filters.items():
@@ -63,7 +89,8 @@ def list_categories(db: Session = Depends(get_db), user: User = Depends(get_curr
.order_by(CategoryGroup.position, CategoryGroup.name) .order_by(CategoryGroup.position, CategoryGroup.name)
.all() .all()
) )
return [group_out(g) for g in groups] counts = _keyword_counts(db, user)
return [group_out(g, counts) for g in groups]
# --- Gruppen --------------------------------------------------------------- # --- Gruppen ---------------------------------------------------------------
@@ -127,6 +154,8 @@ def create_category(
type=group.kind, # erbt die Art der Gruppe type=group.kind, # erbt die Art der Gruppe
icon=payload.icon, icon=payload.icon,
color=payload.color, color=payload.color,
excluded_from_stats=payload.excluded_from_stats,
spending_is_good=payload.spending_is_good,
position=_next_position(db, Category, owner_id=user.id, group_id=group.id), position=_next_position(db, Category, owner_id=user.id, group_id=group.id),
) )
db.add(cat) db.add(cat)
@@ -150,7 +179,10 @@ def update_category(
cat.group_id = group.id cat.group_id = group.id
cat.type = group.kind # Art an die neue Gruppe angleichen cat.type = group.kind # Art an die neue Gruppe angleichen
for key, value in data.items(): for key, value in data.items():
if value is not None or key == "is_archived": # Bei den Schaltern zählt auch False als gesetzter Wert – sonst ließe sich
# ein einmal gesetztes Häkchen nie wieder entfernen.
if value is not None or key in ("is_archived", "excluded_from_stats",
"spending_is_good"):
setattr(cat, key, value) setattr(cat, key, value)
db.commit() db.commit()
db.refresh(cat) db.refresh(cat)
@@ -193,11 +225,25 @@ def _conflict(db: Session, clash: CategorizationRule) -> HTTPException:
) )
def _find_any_rule(db: Session, user: User, pattern: str, exclude_id: int | None = None): def _find_any_rule(
db: Session, user: User, pattern: str, kind: str,
amount_cents: int | None = None, exclude_id: int | None = None,
):
"""Sucht ein manuelles Schlagwort gleicher Art (kind) UND gleichen Betrags.
Dasselbe Wort darf je einmal für Ausgabe, Einnahme und Transfer existieren –
und innerhalb einer Art mehrfach mit verschiedenen Beträgen: Im Personal-
restaurant zahlt man mal 5.00, mal 7.50. Ohne Betrag ist es der Auffang für
alle übrigen Beträge."""
q = db.query(CategorizationRule).filter( q = db.query(CategorizationRule).filter(
CategorizationRule.owner_id == user.id, CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.any, CategorizationRule.match_field == MatchField.any,
CategorizationRule.pattern == pattern, CategorizationRule.pattern == pattern,
CategorizationRule.kind == kind,
)
q = q.filter(
CategorizationRule.amount_cents.is_(None) if amount_cents is None
else CategorizationRule.amount_cents == amount_cents
) )
if exclude_id is not None: if exclude_id is not None:
q = q.filter(CategorizationRule.id != exclude_id) q = q.filter(CategorizationRule.id != exclude_id)
@@ -245,21 +291,26 @@ def update_keyword(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
"""Wort eines Schlagworts ändern (Inline-Bearbeitung im Popup). Da sich das """Schlagwort ändern – Wort und optional Betrag. Ändert sich das Muster,
Muster ändert, werden die Korrektur-Zähler zurückgesetzt.""" werden die Korrektur-Zähler zurückgesetzt."""
rule = _owned_manual_rule(db, rule_id, user) rule = _owned_manual_rule(db, rule_id, user)
pattern = normalize_text(payload.word)[:255] pattern = normalize_text(payload.word)[:255]
if not pattern: if not pattern:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
if pattern != rule.pattern: neuer_betrag = (
clash = _find_any_rule(db, user, pattern, exclude_id=rule.id) payload.amount_cents if "amount_cents" in payload.model_fields_set else rule.amount_cents
)
if pattern != rule.pattern or neuer_betrag != rule.amount_cents:
clash = _find_any_rule(db, user, pattern, rule.kind, neuer_betrag, exclude_id=rule.id)
if clash is not None: if clash is not None:
raise _conflict(db, clash) raise _conflict(db, clash)
if pattern != rule.pattern:
rule.pattern = pattern rule.pattern = pattern
rule.applied_count = 0 rule.applied_count = 0
rule.overridden_count = 0 rule.overridden_count = 0
rule.confirmed = False rule.confirmed = False
# Betrag nur ändern, wenn mitgeschickt (leere Inline-Wortänderung darf ihn nicht löschen). # Betrag nur anfassen, wenn er mitgeschickt wurde. Ausdrücklich null löscht
# ihn (das leere Betragsfeld im Popup), ein Weglassen lässt ihn stehen.
if "amount_cents" in payload.model_fields_set: if "amount_cents" in payload.model_fields_set:
rule.amount_cents = payload.amount_cents rule.amount_cents = payload.amount_cents
db.commit() db.commit()
@@ -301,14 +352,17 @@ def add_keyword(
pattern = normalize_text(payload.word)[:255] pattern = normalize_text(payload.word)[:255]
if not pattern: if not pattern:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
existing = _find_any_rule(db, user, pattern) kind = cat.type.value # "expense" | "income" – Konflikt nur innerhalb derselben Art
# Konflikt nur bei gleichem Wort UND gleichem Betrag: „sv = 5.00" und
# „sv = 7.50" sind zwei gültige Regeln derselben Kategorie.
existing = _find_any_rule(db, user, pattern, kind, payload.amount_cents)
if existing is not None: if existing is not None:
if not existing.is_transfer and existing.category_id == cat.id and existing.amount_cents == payload.amount_cents: if not existing.is_transfer and existing.category_id == cat.id:
return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück
raise _conflict(db, existing) raise _conflict(db, existing)
rule = CategorizationRule( rule = CategorizationRule(
owner_id=user.id, match_field=MatchField.any, pattern=pattern, owner_id=user.id, match_field=MatchField.any, pattern=pattern,
category_id=cat.id, amount_cents=payload.amount_cents, category_id=cat.id, amount_cents=payload.amount_cents, kind=kind,
source=RuleSource.manual, priority=10, source=RuleSource.manual, priority=10,
) )
db.add(rule) db.add(rule)
@@ -321,7 +375,27 @@ def add_keyword(
def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut: def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut:
return TransferKeywordOut(id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents) return TransferKeywordOut(
id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents,
target_account_id=rule.target_account_id,
)
@router.post("/keywords/anwenden")
def apply_rules(vorschau: bool = True, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Regeln nachträglich auf Buchungen ohne Kategorie anwenden.
Regeln greifen sonst nur beim Import. Alles, was vorher hereinkam – oder
hereinkam, bevor es die Regel gab –, bleibt liegen; bei einer Bankanbindung
passiert genau das dauernd.
Mit ``vorschau`` wird nichts geändert. Erst zeigen, dann tun: Ein Übertrag
verschiebt Geld, und das gehört nicht hinter einen unbeschrifteten Knopf.
"""
from ..services import regeln
return regeln.wende_an(db, user, nur_vorschau=vorschau)
@router.get("/transfer-keywords", response_model=list[TransferKeywordOut]) @router.get("/transfer-keywords", response_model=list[TransferKeywordOut])
@@ -346,15 +420,21 @@ def add_transfer_keyword(
pattern = normalize_text(payload.word)[:255] pattern = normalize_text(payload.word)[:255]
if not pattern: if not pattern:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
existing = _find_any_rule(db, user, pattern) target_id = None
if payload.target_account_id is not None:
target_id = get_owned(db, Account, payload.target_account_id, user).id
# Gleiches Wort existiert schon als Transfer-Regel -> aktualisieren (Betrag/Zielkonto).
existing = _find_any_rule(db, user, pattern, "transfer", payload.amount_cents)
if existing is not None: if existing is not None:
if existing.is_transfer and existing.amount_cents == payload.amount_cents: existing.amount_cents = payload.amount_cents
return _transfer_keyword_out(existing) existing.target_account_id = target_id
raise _conflict(db, existing) db.commit()
db.refresh(existing)
return _transfer_keyword_out(existing)
rule = CategorizationRule( rule = CategorizationRule(
owner_id=user.id, match_field=MatchField.any, pattern=pattern, owner_id=user.id, match_field=MatchField.any, pattern=pattern,
category_id=None, amount_cents=payload.amount_cents, is_transfer=True, category_id=None, amount_cents=payload.amount_cents, is_transfer=True,
source=RuleSource.manual, priority=10, target_account_id=target_id, kind="transfer", source=RuleSource.manual, priority=10,
) )
db.add(rule) db.add(rule)
db.commit() db.commit()
+195 -41
View File
@@ -37,10 +37,7 @@ DEFAULT_LAYOUT = [
def _start_day(db: Session, user: User) -> int: def _start_day(db: Session, user: User) -> int:
try: return budget.start_tag(db, user)
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
except ValueError:
return 1
def _label(db: Session, user: User) -> str: def _label(db: Session, user: User) -> str:
@@ -99,47 +96,191 @@ def _expense_groups(db: Session, user: User) -> list[CategoryGroup]:
) )
def _units(groups: list[CategoryGroup], by_category: bool):
"""Liefert je Anzeige-Einheit (name, farbe, [kategorie-ids]). Bei ``by_category``
ist jede Kategorie eine eigene Einheit, sonst jede Gruppe (Summe ihrer Kategorien)."""
for g in groups:
if by_category:
for c in g.categories:
if not c.is_archived:
yield (c.name, c.color, [c.id])
else:
yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived])
def _kein_verlust(groups: list[CategoryGroup]) -> set[int]:
"""Kategorien, deren Ausgabe kein Verlust ist (Sparen, Ruecklagen).
Ihre Betraege stehen neutral statt rot, und mehr als geplant ist bei ihnen
keine Warnung.
"""
return {c.id for g in groups for c in g.categories if c.spending_is_good}
@router.get("/budget-donut") @router.get("/budget-donut")
def budget_donut(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: def budget_donut(
group_by: str = Query(default="group"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
base = base_currency(db, user) base = base_currency(db, user)
label = _label(db, user) label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user)) start, end = budget.period_bounds(label, _start_day(db, user))
planned = budget.effective_planned(db, user, label) planned = budget.effective_planned(db, user, label)
groups = _expense_groups(db, user) groups = _expense_groups(db, user)
by_category = group_by == "category"
segments = [] def collect(values: dict[int, int]):
total = 0 segs, tot = [], 0
for g in groups: for name, color, ids in _units(groups, by_category):
val = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived) val = sum(values.get(i, 0) for i in ids)
if val > 0: if val > 0:
segments.append({"name": g.name, "value": val, "color": g.color}) segs.append({"name": name, "value": val, "color": color})
total += val tot += val
return segs, tot
segments, total = collect(planned)
mode = "planned" mode = "planned"
if total == 0: if total == 0:
actual = budget.actual_expense_by_category(db, user, start, end, base) segments, total = collect(budget.actual_expense_by_category(db, user, label, start, end, base))
for g in groups:
val = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
if val > 0:
segments.append({"name": g.name, "value": val, "color": g.color})
total += val
mode = "actual" mode = "actual"
return {"segments": segments, "total": total, "currency": base, "mode": mode, "label": label} return {"segments": segments, "total": total, "currency": base, "mode": mode,
"label": label, "group_by": "category" if by_category else "group"}
@router.get("/expense-share")
def expense_share(
group_by: str = Query(default="category"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
"""Tatsächliche Ausgaben des laufenden Budget-Monats je Kategorie/Gruppe.
Anders als der Budget-Donut zeigt das rein die Ist-Ausgaben – unabhängig
davon, ob für den Monat überhaupt geplant wurde. Die Anteile rechnet die
Oberfläche aus `amount` und `total`, damit sich gerundete Prozente nicht zu
99,9 % addieren."""
base = base_currency(db, user)
label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user))
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
# Das Geplante kommt mit, damit die Karte zeigen kann, ob ein Anteil noch
# im Rahmen liegt. Ohne Plan wird nichts behauptet: „ueber Budget" waere
# sonst die Aussage ueber jede Kategorie, fuer die nie eine gemacht wurde.
planned = budget.effective_planned(db, user, label)
by_category = group_by != "group"
# Als Liste: ``_units`` ist ein Generator, und er wird hier zweimal
# gebraucht – beim zweiten Mal waere er leer und das Geplante 0.
gruppen = _expense_groups(db, user)
gut = _kein_verlust(gruppen)
einheiten = list(_units(gruppen, by_category))
rows = []
for name, color, ids in einheiten:
val = sum(actual.get(i, 0) for i in ids)
if val > 0:
geplant = sum(planned.get(i, 0) for i in ids)
# Ueberschritten ist die Zeile an dem, was WIRKLICH zu viel war –
# das Sparen bleibt beim Urteil draussen, nicht beim Betrag.
echt_geplant = sum(planned.get(i, 0) for i in ids if i not in gut)
echt_ist = sum(actual.get(i, 0) for i in ids if i not in gut)
rows.append({"name": name, "color": color, "amount": val,
"planned": geplant,
"over": echt_geplant > 0 and echt_ist > echt_geplant,
"good": bool(ids) and all(i in gut for i in ids),
# Womit sich die Zeile aufschluesseln laesst.
"ids": list(ids)})
rows.sort(key=lambda r: r["amount"], reverse=True)
# Das Geplante ueber ALLE Ausgaben-Einheiten, nicht nur die mit Ausgaben:
# Der Monatsplan umfasst auch, wofuer noch nichts ausgegeben wurde.
gesamt = sum(r["amount"] for r in rows)
gesamt_geplant = sum(planned.get(i, 0) for _, _, ids in einheiten for i in ids)
return {
"rows": rows,
"total": gesamt,
"total_planned": gesamt_geplant,
"total_over": gesamt_geplant > 0 and gesamt > gesamt_geplant,
# Der Zeitraum gehoert dazu: Bei einem Budget-Monat, der nicht am
# Ersten beginnt, ist sonst nicht nachvollziehbar, welche Buchungen
# ueberhaupt gezaehlt wurden.
"von": start.isoformat(),
"bis": end.isoformat(),
"currency": base,
"label": label,
"group_by": "category" if by_category else "group",
}
@router.get("/expense-share/buchungen")
def expense_share_rows(
ids: list[int] = Query(default=[]),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
"""Die einzelnen Buchungen hinter einem Anteil.
Eine Zahl, die man nicht aufschluesseln kann, muss man glauben. Diese hier
kommt aus derselben Stelle wie die Summe auf der Karte – was hier steht,
ist genau das, was dort gezaehlt wurde, nicht eine zweite Suche mit
aehnlichen Bedingungen.
"""
base = base_currency(db, user)
label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user))
gesucht = set(ids)
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
zeilen = []
for cat_id, txn, cents in budget.expense_rows(db, user, label, start, end, base):
if gesucht and cat_id not in gesucht:
continue
konto = konten.get(txn.account_id)
zeilen.append({
"id": txn.id,
"date": txn.date.isoformat(),
"title": txn.payee or txn.description or "",
"account": konto.name if konto else "",
# Beides: Der Betrag, wie er auf dem Konto steht, UND der, mit dem
# gerechnet wurde. Weichen sie ab, liegt es an der Umrechnung – und
# das sieht man dann sofort.
"amount_cents": txn.amount_cents,
"currency": konto.currency if konto else base,
"base_cents": cents,
"type": txn.type.value,
"budget_period_manuell": txn.budget_period is not None,
})
zeilen.sort(key=lambda z: (z["date"], z["id"]))
return {"rows": zeilen, "total": sum(z["base_cents"] for z in zeilen),
"currency": base, "von": start.isoformat(), "bis": end.isoformat()}
@router.get("/budget-progress") @router.get("/budget-progress")
def budget_progress(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: def budget_progress(
group_by: str = Query(default="group"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
base = base_currency(db, user) base = base_currency(db, user)
label = _label(db, user) label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user)) start, end = budget.period_bounds(label, _start_day(db, user))
planned = budget.effective_planned(db, user, label) planned = budget.effective_planned(db, user, label)
actual = budget.actual_expense_by_category(db, user, start, end, base) actual = budget.actual_expense_by_category(db, user, label, start, end, base)
by_category = group_by == "category"
gruppen = _expense_groups(db, user)
gut = _kein_verlust(gruppen)
rows = [] rows = []
for g in _expense_groups(db, user): for name, color, ids in _units(gruppen, by_category):
gp = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived) gp = sum(planned.get(i, 0) for i in ids)
ga = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived) ga = sum(actual.get(i, 0) for i in ids)
if gp > 0 or ga > 0: if gp > 0 or ga > 0:
rows.append({"name": g.name, "color": g.color, "planned": gp, "actual": ga, "over": ga > gp}) echt_geplant = sum(planned.get(i, 0) for i in ids if i not in gut)
return {"rows": rows, "currency": base, "label": label} echt_ist = sum(actual.get(i, 0) for i in ids if i not in gut)
rows.append({"name": name, "color": color, "planned": gp, "actual": ga,
"over": echt_ist > echt_geplant,
"good": bool(ids) and all(i in gut for i in ids)})
return {"rows": rows, "currency": base, "label": label,
"group_by": "category" if by_category else "group"}
@router.get("/income-expense") @router.get("/income-expense")
@@ -147,8 +288,8 @@ def income_expense(db: Session = Depends(get_db), user: User = Depends(get_curre
base = base_currency(db, user) base = base_currency(db, user)
label = _label(db, user) label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user)) start, end = budget.period_bounds(label, _start_day(db, user))
expense = sum(budget.actual_expense_by_category(db, user, start, end, base).values()) expense = sum(budget.actual_expense_by_category(db, user, label, start, end, base).values())
income = sum(budget.income_by_category(db, user, start, end, base).values()) income = sum(budget.income_by_category(db, user, label, start, end, base).values())
return {"income": income, "expense": expense, "net": income - expense, "currency": base, "label": label} return {"income": income, "expense": expense, "net": income - expense, "currency": base, "label": label}
@@ -164,16 +305,24 @@ def dashboard_balances(db: Session = Depends(get_db), user: User = Depends(get_c
) )
rows = [] rows = []
total = 0 total = 0
gebunden = 0
for a in accounts: for a in accounts:
bb = balances.account_balance_base_cents(db, a, base, today) bb = balances.account_balance_base_cents(db, a, base, today)
rows.append({ rows.append({
"id": a.id, "name": a.name, "icon": a.icon, "color": a.color, "id": a.id, "name": a.name, "icon": a.icon, "color": a.color,
"currency": a.currency, "balance_cents": balances.account_balance_cents(db, a), "currency": a.currency, "balance_cents": balances.account_balance_cents(db, a),
"balance_base_cents": bb, "archived": a.is_archived, "balance_base_cents": bb, "archived": a.is_archived, "locked": a.is_locked,
}) })
if not a.is_archived: if a.is_archived:
continue
# Gesperrtes Geld steht neben der Summe, nicht darin: Die Frage, die
# diese Zahl beantwortet, ist „wie viel habe ich zur Verfuegung".
# Verschwinden darf es trotzdem nicht – es gehoert dem Nutzer.
if a.is_locked:
gebunden += bb
else:
total += bb total += bb
return {"accounts": rows, "total": total, "currency": base} return {"accounts": rows, "total": total, "gebunden": gebunden, "currency": base}
@router.get("/net-worth") @router.get("/net-worth")
@@ -195,17 +344,22 @@ def net_worth(
opening_eur = sum(to_eur(a.opening_balance_cents, a.currency, a.opening_date or start) for a in accounts) opening_eur = sum(to_eur(a.opening_balance_cents, a.currency, a.opening_date or start) for a in accounts)
def zaehlt(acc: Account | None, on: date) -> bool:
"""Buchungen bis zum Stichtag stecken schon im Anfangssaldo."""
return acc is not None and (acc.opening_date is None or on > acc.opening_date)
def eur_effect(t: Transaction) -> int: def eur_effect(t: Transaction) -> int:
if t.type == TxnType.income: # Jede Seite wird für sich geprüft: Der Stichtag des Quellkontos kann
return t.amount_eur_cents # anders liegen als der des Zielkontos.
if t.type == TxnType.expense: effekt = 0
return -t.amount_eur_cents if zaehlt(acc_by_id.get(t.account_id), t.date):
# Transfer: Netto-Effekt aufs Vermögen = EUR-Wert Zugang - EUR-Wert Abgang. effekt += t.amount_eur_cents if t.type == TxnType.income else -t.amount_eur_cents
to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None if t.type == TxnType.transfer:
if to_acc is None: to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None
return 0 # Zielkonto archiviert/gelöscht: ignorieren if zaehlt(to_acc, t.date):
credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
return to_eur(credited, to_acc.currency, t.date) - t.amount_eur_cents effekt += to_eur(credited, to_acc.currency, t.date)
return effekt
txns = db.query(Transaction).filter( txns = db.query(Transaction).filter(
Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0]) Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0])
+2
View File
@@ -26,9 +26,11 @@ async def preview(
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
candidates = ocr.parse_transactions(text) candidates = ocr.parse_transactions(text)
rows = csv_import.detect_rows(db, user, account, candidates) rows = csv_import.detect_rows(db, user, account, candidates)
pages = ocr.render_pages(data, file.filename or "", file.content_type or "")
return { return {
"rows": rows, "rows": rows,
"text": text[:8000], "text": text[:8000],
"count": len(rows), "count": len(rows),
"filename": file.filename or "", "filename": file.filename or "",
"pages": pages,
} }
+137 -4
View File
@@ -2,12 +2,15 @@
import json import json
from fastapi import APIRouter, Depends, File, Form, UploadFile, status from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, get_owned from ..deps import get_current_user, get_owned
from ..models import Account, AmountMode, ImportBatch, ImportTemplate, User from fastapi.responses import Response
from ..models import (PendingUpload, Account, AccountType, AmountMode, ImportBatch,
ImportStatus, ImportTemplate, User)
from ..schemas import ( from ..schemas import (
ImportBatchOut, ImportBatchOut,
ImportCommitIn, ImportCommitIn,
@@ -26,6 +29,7 @@ def _to_out(tpl: ImportTemplate) -> ImportTemplateOut:
date_format=tpl.date_format, encoding=tpl.encoding, has_header=tpl.has_header, date_format=tpl.date_format, encoding=tpl.encoding, has_header=tpl.has_header,
skip_rows=tpl.skip_rows, amount_mode=tpl.amount_mode, skip_rows=tpl.skip_rows, amount_mode=tpl.amount_mode,
column_map=json.loads(tpl.column_map or "{}"), column_map=json.loads(tpl.column_map or "{}"),
filter_column=tpl.filter_column, filter_value=tpl.filter_value,
) )
@@ -51,6 +55,7 @@ def create_template(payload: ImportTemplateCreate, db: Session = Depends(get_db)
decimal_sep=payload.decimal_sep, date_format=payload.date_format, encoding=payload.encoding, decimal_sep=payload.decimal_sep, date_format=payload.date_format, encoding=payload.encoding,
has_header=payload.has_header, skip_rows=payload.skip_rows, amount_mode=payload.amount_mode, has_header=payload.has_header, skip_rows=payload.skip_rows, amount_mode=payload.amount_mode,
column_map=json.dumps(payload.column_map), column_map=json.dumps(payload.column_map),
filter_column=payload.filter_column, filter_value=payload.filter_value,
) )
db.add(tpl) db.add(tpl)
db.commit() db.commit()
@@ -86,6 +91,71 @@ def _transient(user_id: int, **fields) -> ImportTemplate:
return ImportTemplate(owner_id=user_id, name="_transient", **fields) return ImportTemplate(owner_id=user_id, name="_transient", **fields)
@router.post("/detect")
async def detect_format(
file: UploadFile = File(...),
encoding: str = Form("utf-8"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
"""Format UND Konto der Datei erkennen.
Das Konto gehoerte von Anfang an hierher: Es ist die gefaehrlichste
Entscheidung des ganzen Imports – ein Auszug im falschen Konto verfaelscht
zwei Salden auf einmal – und ``erkenne_konto`` konnte sie laengst. Benutzt
hat die Funktion bisher nur der Telefon-Kurzbefehl.
``erkenne_konto`` raet nicht: Bleibt es mehrdeutig, kommt ``None`` zurueck
und der Nutzer waehlt wie bisher.
"""
daten = await file.read()
erkannt = csv_import.erkenne_format(daten, encoding)
konto = csv_import.erkenne_konto(db, user, daten, erkannt)
erkannt["konto"] = konto
acc_id = konto.get("account_id")
if acc_id:
acc = db.get(Account, acc_id)
if acc is not None and acc.owner_id == user.id:
_kreditkarten_regel(erkannt, acc)
return erkannt
def _kreditkarten_regel(erkannt: dict, acc: Account) -> None:
"""Bei einer Kreditkarte ist + eine Ausgabe.
Die Regel steht serverseitig und an EINER Stelle. Im Frontend gebaut
zerbraeche sie an der Reihenfolge: ``setAccountId`` und die Formaterkennung
laufen dort im selben Durchgang, die Regel laese also noch den alten
Zustand.
Eng gefasst, mit Absicht:
- Nur ``signed``. Bei getrennten Soll-/Haben-Spalten benennen die Spalten
die Richtung selbst, und ``abs_expense`` wurde bewusst gewaehlt.
- Nur mit einer Betragsspalte.
- Nur, wenn das Konto sicher erkannt wurde – sonst raet man zweimal.
Umgeschaltet, nicht erzwungen: Der Modus steht unter „Format" und laesst
sich aendern. Und in der Pruefansicht steht, dass umgekehrt gelesen wird –
eine stille Umkehr waere schlimmer als gar keine.
"""
if acc.type != AccountType.kreditkarte or not erkannt.get("ok"):
return
config = erkannt.get("config") or {}
if config.get("amount_mode") != AmountMode.signed.value:
return
if (config.get("column_map") or {}).get("amount") is None:
return
config["amount_mode"] = AmountMode.signed_inverted.value
erkannt["kreditkarte"] = True
erkannt["begruendung"] = (
(erkannt.get("begruendung") or "")
+ f" · Kreditkarte „{acc.name}“: + im Auszug = Ausgabe"
).strip(" ·")
@router.post("/raw") @router.post("/raw")
async def raw_preview( async def raw_preview(
file: UploadFile = File(...), file: UploadFile = File(...),
@@ -112,6 +182,8 @@ async def preview(
skip_rows: int = Form(0), skip_rows: int = Form(0),
amount_mode: str = Form("signed"), amount_mode: str = Form("signed"),
column_map: str = Form("{}"), column_map: str = Form("{}"),
filter_column: int | None = Form(None),
filter_value: str | None = Form(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> dict: ) -> dict:
@@ -121,9 +193,15 @@ async def preview(
user.id, delimiter=delimiter, decimal_sep=decimal_sep, date_format=date_format, user.id, delimiter=delimiter, decimal_sep=decimal_sep, date_format=date_format,
encoding=encoding, has_header=has_header, skip_rows=skip_rows, encoding=encoding, has_header=has_header, skip_rows=skip_rows,
amount_mode=AmountMode(amount_mode), column_map=column_map, amount_mode=AmountMode(amount_mode), column_map=column_map,
filter_column=filter_column, filter_value=filter_value,
) )
rows = csv_import.build_preview(db, user, account, tpl, data) rows = csv_import.build_preview(db, user, account, tpl, data)
return {"rows": rows, "filename": file.filename or ""} return {
"rows": rows,
"filename": file.filename or "",
# Abgleich gegen die Saldo-Spalte der Datei (falls zugeordnet).
"reconciliation": csv_import.reconcile(rows),
}
# --- Verbuchen / Undo ------------------------------------------------------ # --- Verbuchen / Undo ------------------------------------------------------
@@ -132,7 +210,8 @@ async def preview(
@router.post("/commit", response_model=ImportBatchOut) @router.post("/commit", response_model=ImportBatchOut)
def commit(payload: ImportCommitIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)): def commit(payload: ImportCommitIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
account = get_owned(db, Account, payload.account_id, user) account = get_owned(db, Account, payload.account_id, user)
return csv_import.commit_import(db, user, account, payload.template_id, payload.filename, payload.rows) return csv_import.commit_import(db, user, account, payload.template_id, payload.filename,
payload.rows, preset=payload.preset)
@router.get("/batches", response_model=list[ImportBatchOut]) @router.get("/batches", response_model=list[ImportBatchOut])
@@ -150,3 +229,57 @@ def undo(batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_
batch = get_owned(db, ImportBatch, batch_id, user) batch = get_owned(db, ImportBatch, batch_id, user)
deleted = csv_import.undo_batch(db, user, batch) deleted = csv_import.undo_batch(db, user, batch)
return {"deleted": deleted} return {"deleted": deleted}
@router.delete("/batches/{batch_id}/record", status_code=status.HTTP_204_NO_CONTENT)
def remove_batch_record(
batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
"""Entfernt einen rückgängig gemachten Eintrag aus dem Verlauf.
Nur für bereits zurückgenommene Importe: Solange ein Import steht, ist der
Eintrag die einzige Möglichkeit, seine Buchungen wieder loszuwerden."""
batch = get_owned(db, ImportBatch, batch_id, user)
if batch.status != ImportStatus.undone:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Nur rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen.",
)
db.delete(batch)
db.commit()
# --- Vom Telefon abgelieferte Dateien ---------------------------------------
@router.get("/uploads")
def list_uploads(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
"""Wartende Dateien. Der Inhalt bleibt hier draussen – nur die Eckdaten."""
zeilen = (
db.query(PendingUpload)
.filter(PendingUpload.owner_id == user.id)
.order_by(PendingUpload.created_at.desc(), PendingUpload.id.desc())
.all()
)
return [
{"id": u.id, "filename": u.filename, "account_id": u.account_id,
"size_bytes": u.size_bytes, "created_at": u.created_at.isoformat() if u.created_at else None}
for u in zeilen
]
@router.get("/uploads/{upload_id}/content")
def upload_content(upload_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Die Datei selbst – damit die Import-Ansicht sie wie eine lokale behandelt."""
upload = get_owned(db, PendingUpload, upload_id, user)
return Response(
content=upload.content,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{upload.filename}"'},
)
@router.delete("/uploads/{upload_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_upload(upload_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
db.delete(get_owned(db, PendingUpload, upload_id, user))
db.commit()
+263
View File
@@ -0,0 +1,263 @@
"""Eingang extern erfasster Zahlungen und die Gerätemarken dafür.
Der Eingang zeigt, was per Kurzbefehl (später auch per Mail) hereinkam. Die
Einträge bleiben nach dem Buchen bestehen: Der spätere CSV-Import gleicht
gegen sie ab, damit dieselbe Zahlung nicht ein zweites Mal verbucht wird.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned, require_cookie_session
from ..models import (
Account,
ApiToken,
InboxItem,
InboxStatus,
Transaction,
TxnType,
User,
)
from ..schemas import ApiTokenCreate, ApiTokenCreated, ApiTokenOut, InboxItemOut
from ..security import generate_session_token, hash_session_token
from ..services import eingang, uebertrag_buchen
from ..services.uebertrag_buchen import UebertragFehler
router = APIRouter(prefix="/inbox", tags=["inbox"])
# --- Gerätemarken ------------------------------------------------------------
@router.get("/tokens", response_model=list[ApiTokenOut])
def list_tokens(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return (
db.query(ApiToken)
.filter(ApiToken.owner_id == user.id)
.order_by(ApiToken.created_at.desc())
.all()
)
@router.post("/tokens", response_model=ApiTokenCreated, status_code=status.HTTP_201_CREATED)
def create_token(
payload: ApiTokenCreate,
db: Session = Depends(get_db),
user: User = Depends(require_cookie_session),
):
"""Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen.
Bewusst ``require_cookie_session``: Eine App-Marke darf keine weitere
ausstellen. Sonst stellte ein Dieb sich Ersatzschlüssel aus und das
Widerrufen der gestohlenen Marke liefe ins Leere.
"""
if payload.default_account_id is not None:
get_owned(db, Account, payload.default_account_id, user)
klartext = generate_session_token()
marke = ApiToken(
owner_id=user.id,
name=payload.name.strip(),
token_hash=hash_session_token(klartext),
scope=payload.scope,
default_account_id=payload.default_account_id,
)
db.add(marke)
db.commit()
db.refresh(marke)
return ApiTokenCreated(**ApiTokenOut.model_validate(marke).model_dump(), token=klartext)
@router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_token(token_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
marke = get_owned(db, ApiToken, token_id, user)
db.delete(marke)
db.commit()
# --- Eingang -----------------------------------------------------------------
def _uebertrags_fehler(e: UebertragFehler) -> HTTPException:
"""409, wenn sich der Fehler mit ``trotzdem`` übergehen lässt, sonst 400.
Der Unterschied ist der Oberfläche wichtig: 409 heisst nachfragen und noch
einmal senden, 400 heisst, es geht so nicht.
"""
return HTTPException(
status_code=status.HTTP_409_CONFLICT if e.nachfragen
else status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
def _gegenkonten(db: Session, user: User, eintraege: list[InboxItem]) -> dict[int, int]:
"""Je Eintrag das Gegenkonto, falls seine Buchung ein Übertrag ist.
Als Sammelabfrage und nicht je Zeile: Die Liste geht bis 200 Einträge.
"""
txn_ids = {e.transaction_id for e in eintraege if e.transaction_id is not None}
if not txn_ids:
return {}
buchungen = {
b.id: b
for b in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.id.in_(txn_ids),
Transaction.type == TxnType.transfer,
)
}
gegen: dict[int, int] = {}
for e in eintraege:
buchung = buchungen.get(e.transaction_id)
if buchung is None:
continue
# Das jeweils ANDERE Konto, aus Sicht dieses Eintrags.
gegen[e.id] = (buchung.to_account_id if buchung.account_id == e.account_id
else buchung.account_id)
return gegen
def _out(db: Session, user: User, eintraege: list[InboxItem]) -> list[InboxItemOut]:
gegen = _gegenkonten(db, user, eintraege)
return [
InboxItemOut.model_validate(e).model_copy(
update={"transfer_account_id": gegen.get(e.id)})
for e in eintraege
]
@router.get("", response_model=list[InboxItemOut])
def list_inbox(
limit: int = 200,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return _out(db, user, (
db.query(InboxItem)
.filter(InboxItem.owner_id == user.id, InboxItem.status != InboxStatus.discarded)
.order_by(InboxItem.date.desc(), InboxItem.id.desc())
.limit(min(limit, 1000))
.all()
))
@router.post("/{item_id}/book", response_model=InboxItemOut)
def book_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
eintrag = get_owned(db, InboxItem, item_id, user)
if eintrag.account_id is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Kein Konto zugeordnet")
if eingang.buche(db, user, eintrag) is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Lässt sich nicht buchen")
db.commit()
eingang.suche_uebertraege(db, user)
db.refresh(eintrag)
return _out(db, user, [eintrag])[0]
class InboxTransferIn(BaseModel):
to_account_id: int
# Nur setzen, wenn man es besser weiss als Certain – siehe unten.
trotzdem: bool = False
@router.post("/{item_id}/transfer", response_model=InboxItemOut)
def book_as_transfer(item_id: int, payload: InboxTransferIn,
db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Einen Vorschlag als Kontoübertrag buchen statt als Ausgabe oder Einnahme.
Es entsteht EINE Buchung, die das Geld vom einen Konto ins andere bewegt.
Sie zählt auf beiden Seiten im Saldo und in keiner Auswertung – ein
Übertrag ist keine Ausgabe.
"""
eintrag = get_owned(db, InboxItem, item_id, user)
ziel = get_owned(db, Account, payload.to_account_id, user)
try:
uebertrag_buchen.aus_eingang(db, user, eintrag, ziel, payload.trotzdem)
except UebertragFehler as e:
db.rollback()
raise _uebertrags_fehler(e) from e
db.commit()
db.refresh(eintrag)
return _out(db, user, [eintrag])[0]
@router.post("/book-all")
def book_all(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
"""Alle offenen Vorschläge buchen, die ein Konto haben.
Unklare bleiben liegen: Bei ihnen fehlt etwas oder die Erkennung war
unsicher – die soll man ansehen, nicht im Stapel durchwinken.
"""
offen = (
db.query(InboxItem)
.filter(InboxItem.owner_id == user.id,
InboxItem.status == InboxStatus.pending,
InboxItem.account_id.isnot(None))
.all()
)
gebucht = sum(1 for e in offen if eingang.buche(db, user, e) is not None)
db.commit()
return {"gebucht": gebucht, "uebertraege": eingang.suche_uebertraege(db, user)}
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def discard_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Eintrag verwerfen – samt der Buchung, die daraus entstanden ist.
Der Eintrag selbst bleibt als ``discarded`` stehen. Sonst wäre die Zahlung
beim nächsten Auszug wieder neu, obwohl der Nutzer sie bewusst weggeworfen
hat; so kann der Abgleich sie weiterhin erkennen.
"""
eintrag = get_owned(db, InboxItem, item_id, user)
# Bei einem Übertrag zeigen ZWEI Einträge auf dieselbe Buchung. Löscht man
# sie, bliebe die andere Seite als „gebucht" ohne Buchung stehen – der
# Fremdschlüssel setzt nur ihre Verweisspalte auf NULL.
geschwister = (
db.query(InboxItem)
.filter(InboxItem.owner_id == user.id,
InboxItem.transaction_id == eintrag.transaction_id)
.all()
if eintrag.transaction_id is not None else [eintrag]
)
if eintrag.transaction_id is not None:
txn = db.get(Transaction, eintrag.transaction_id)
if txn is not None and txn.owner_id == user.id:
db.delete(txn)
for e in geschwister:
e.status = InboxStatus.discarded
e.transaction_id = None
db.commit()
@router.post("/{item_id}/category/{category_id}", response_model=InboxItemOut)
def set_category(
item_id: int,
category_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Kategorie setzen – auf dem Eintrag UND auf der erzeugten Buchung."""
from ..models import Category
eintrag = get_owned(db, InboxItem, item_id, user)
kategorie = get_owned(db, Category, category_id, user)
txn = (db.get(Transaction, eintrag.transaction_id)
if eintrag.transaction_id is not None else None)
if txn is not None and txn.owner_id == user.id:
# Ein Übertrag ist keine Ausgabe. Ohne diese Sperre landete über den
# Eingang eine Kategorie auf einer Übertrags-Buchung – genau das, was
# das Buchungsformular beim Anlegen verhindert.
if txn.type == TxnType.transfer:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ein Kontoübertrag bekommt keine Kategorie.",
)
txn.category_id = kategorie.id
eintrag.category_id = kategorie.id
db.commit()
db.refresh(eintrag)
return _out(db, user, [eintrag])[0]
+150
View File
@@ -0,0 +1,150 @@
"""Postfächer einrichten und Zahlungs-Mails abholen."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Account, MailSender, MailSource, User
from ..schemas import MailSenderIn, MailSenderOut, MailSourceIn, MailSourceOut
from ..services import geheimnis, mail_import
router = APIRouter(prefix="/mail", tags=["mail"])
def _aus(quelle: MailSource) -> MailSourceOut:
out = MailSourceOut.model_validate(quelle)
out.password_set = bool(quelle.password_enc)
return out
@router.get("/sources", response_model=list[MailSourceOut])
def list_sources(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
quellen = (
db.query(MailSource).filter(MailSource.owner_id == user.id).order_by(MailSource.id).all()
)
return [_aus(q) for q in quellen]
@router.post("/sources", response_model=MailSourceOut, status_code=status.HTTP_201_CREATED)
def create_source(
payload: MailSourceIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
if not payload.password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort fehlt")
quelle = MailSource(
owner_id=user.id,
**payload.model_dump(exclude={"password"}),
password_enc=geheimnis.verschluesseln(payload.password),
)
db.add(quelle)
db.commit()
db.refresh(quelle)
return _aus(quelle)
@router.put("/sources/{source_id}", response_model=MailSourceOut)
def update_source(
source_id: int,
payload: MailSourceIn,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
quelle = get_owned(db, MailSource, source_id, user)
for schluessel, wert in payload.model_dump(exclude={"password"}).items():
setattr(quelle, schluessel, wert)
# Leeres Passwortfeld heisst „unverändert" – sonst müsste man es bei jeder
# Kleinigkeit erneut eintippen.
if payload.password:
quelle.password_enc = geheimnis.verschluesseln(payload.password)
db.commit()
db.refresh(quelle)
return _aus(quelle)
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_source(source_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
db.delete(get_owned(db, MailSource, source_id, user))
db.commit()
@router.post("/sources/{source_id}/test")
def test_source(source_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Verbindung prüfen und die Ordner des Postfachs zurückgeben."""
quelle = get_owned(db, MailSource, source_id, user)
try:
return mail_import.teste(quelle)
except mail_import.MailFehler as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
@router.post("/sources/{source_id}/fetch")
def fetch_source(source_id: int, tage: int | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Mails abholen. Mit ``tage`` ab heute minus so vielen Tagen statt ab dem
letzten Abruf – für Mails, die schon vor der Einrichtung im Postfach lagen."""
quelle = get_owned(db, MailSource, source_id, user)
if tage is not None and not 1 <= tage <= 365:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Der Zeitraum muss zwischen 1 und 365 Tagen liegen.")
try:
return mail_import.hole_neue(db, quelle, tage)
except mail_import.MailFehler as e:
quelle.last_error = str(e)[:500]
db.commit()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# --- Absender-Zuordnung ------------------------------------------------------
@router.get("/sources/{source_id}/senders", response_model=list[MailSenderOut])
def list_senders(source_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
get_owned(db, MailSource, source_id, user)
return (
db.query(MailSender)
.filter(MailSender.source_id == source_id)
.order_by(MailSender.id)
.all()
)
@router.post("/sources/{source_id}/senders", response_model=MailSenderOut,
status_code=status.HTTP_201_CREATED)
def create_sender(
source_id: int,
payload: MailSenderIn,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
get_owned(db, MailSource, source_id, user)
if payload.account_id is not None:
get_owned(db, Account, payload.account_id, user)
absender = MailSender(source_id=source_id, owner_id=user.id, **payload.model_dump())
db.add(absender)
db.commit()
db.refresh(absender)
return absender
@router.put("/senders/{sender_id}", response_model=MailSenderOut)
def update_sender(
sender_id: int,
payload: MailSenderIn,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
absender = get_owned(db, MailSender, sender_id, user)
if payload.account_id is not None:
get_owned(db, Account, payload.account_id, user)
for schluessel, wert in payload.model_dump().items():
setattr(absender, schluessel, wert)
db.commit()
db.refresh(absender)
return absender
@router.delete("/senders/{sender_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_sender(sender_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
db.delete(get_owned(db, MailSender, sender_id, user))
db.commit()
+425
View File
@@ -0,0 +1,425 @@
"""Anmelden über einen fremden Dienst – und ein bestehendes Konto damit verknüpfen.
Zwei Wege durch dieselbe Strecke:
**Verknüpfen** (angemeldet). Der eigentliche Weg für alle, die schon ein Konto
haben: einmal wie gewohnt anmelden, in den Einstellungen „Mit … verknüpfen",
fertig. Danach geht beides – Passwort und SSO.
**Anmelden** (nicht angemeldet). Ist die Kennung verknüpft, wird die Sitzung
angelegt. Ist sie es nicht, entsteht je nach Einstellung ein NEUES Konto – oder
die Anmeldung wird abgewiesen (Vorgabe).
Was es bewusst nicht gibt: automatisches Zusammenführen über die E-Mail. Es
wäre der bequeme Weg und der gefährliche – wer beim Anbieter eine fremde
Adresse einträgt, übernähme damit ein fremdes Konto.
Der zweite Faktor von Certain wird beim SSO-Login übersprungen: Der ist beim
Anbieter schon passiert, und ein zweites Mal fragen hiesse, ihm nicht zu
glauben, obwohl man ihn gerade zur Anmeldung ermächtigt hat.
"""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from .. import oidc_config
from ..config import get_settings
from ..database import get_db
from ..deps import get_current_user
from ..models import OidcState, Role, User, UserIdentity
from ..services import oidc as oidc_service
from ..services import sessions
router = APIRouter(prefix="/auth/oidc", tags=["auth"])
# Wie lange ein begonnener Anmeldeversuch gilt. Kurz: In dieser Zeit soll der
# Nutzer nur beim Anbieter auf "Anmelden" druecken.
STATE_TTL_MINUTEN = 15
def _now() -> datetime:
return datetime.now(timezone.utc)
def _redirect_uri(request: Request, provider: str) -> str:
"""Die Adresse, an die der Anbieter zurueckschickt.
Sie muss beim Anbieter EXAKT so eingetragen sein – beim Einloesen des Codes
wird sie noch einmal mitgeschickt und dort verglichen. Ein Zeichen
Unterschied, und die Anmeldung scheitert.
Steht ``APP_PUBLIC_BASE_URL``, gilt die. Sonst wird sie aus der Anfrage
abgeleitet – das genuegt in der Entwicklung, geht hinter einem Reverse
Proxy aber schief: Dort kaeme die INTERNE Adresse heraus
(http://10.0.0.5:8071/...), weil uvicorn die Weiterleitungs-Kopfzeilen
nicht auswertet. Genau deshalb gibt es die Einstellung.
"""
pfad = request.url_for("oidc_callback", provider=provider).path
basis = get_settings().public_base_url.strip().rstrip("/")
return f"{basis}{pfad}" if basis else str(request.url_for(
"oidc_callback", provider=provider))
def _fehlerseite(weiter: str, meldung: str) -> RedirectResponse:
"""Zurueck in die Oberflaeche, mit dem Grund in der Adresse.
Kein JSON: Hier landet ein BROWSER nach einer Weiterleitung, kein
Programm. Eine nackte Fehlerantwort waere eine weisse Seite.
"""
from urllib.parse import quote
return RedirectResponse(f"{weiter}?oidc_error={quote(meldung[:300])}", status_code=303)
def _sicheres_ziel(weiter: str | None) -> str:
"""Nur Ziele auf dieser Seite.
Ohne diese Pruefung waere der Endpunkt eine offene Weiterleitung: Ein Link
auf ``…/start?weiter=https://woanders`` schickte den Nutzer nach der
Anmeldung auf eine fremde Seite, die wie ein Teil von Certain aussieht.
"""
if not weiter or not weiter.startswith("/") or weiter.startswith("//"):
return "/"
return weiter[:255]
@router.get("/providers")
def list_providers() -> dict:
"""Welche Anmeldedienste eingerichtet sind.
Ohne Anmeldung erreichbar: Die Login-Seite muss vor der Anmeldung wissen,
ob sie einen Knopf zeigen soll. Client-Secrets stehen hier nie.
"""
return {
"providers": [
{"name": p.name, "label": p.label}
for p in oidc_config.providers().values()
]
}
def _hole_provider(provider: str):
p = oidc_config.get(provider)
if p is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Kein Anmeldedienst '{provider}' eingerichtet.",
)
return p
def _beginne(db: Session, request: Request, provider: str, weiter: str | None,
link_user: User | None) -> RedirectResponse:
p = _hole_provider(provider)
ziel = _redirect_uri(request, p.name)
zurueck = _sicheres_ziel(weiter) if link_user is None else _sicheres_ziel(weiter or "/konto")
try:
begonnen = oidc_service.start(p, ziel)
except oidc_service.OidcFehler as e:
# Hier steht ein BROWSER, der einem Link gefolgt ist – keine Antwort auf
# fetch(). Eine Fehlerantwort waere eine Seite mit rohem JSON. Also
# zurueck in die Oberflaeche, mit dem Grund in der Adresse, genau wie
# bei der Rueckkehr vom Anbieter.
return _fehlerseite(zurueck if link_user is not None else "/login", str(e))
# Abgelaufene Versuche wegraeumen – sonst waechst die Tabelle still.
db.query(OidcState).filter(OidcState.expires_at < _now()).delete()
db.add(OidcState(
state=begonnen["state"],
provider=p.name,
nonce=begonnen["nonce"],
code_verifier=begonnen["code_verifier"],
redirect_uri=ziel,
link_user_id=link_user.id if link_user is not None else None,
weiter=_sicheres_ziel(weiter),
expires_at=_now() + timedelta(minutes=STATE_TTL_MINUTEN),
))
db.commit()
return RedirectResponse(begonnen["url"], status_code=303)
@router.get("/{provider}/start")
def oidc_start(provider: str, request: Request, weiter: str | None = Query(default=None),
db: Session = Depends(get_db)):
"""Anmeldung beginnen. Ohne Anmeldung erreichbar – das ist der Sinn."""
return _beginne(db, request, provider, weiter, None)
@router.get("/{provider}/link")
def oidc_link(provider: str, request: Request, weiter: str | None = Query(default=None),
db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Das eigene, bestehende Konto mit dem Anbieter verknuepfen."""
return _beginne(db, request, provider, weiter or "/konto", user)
def _freier_benutzername(db: Session, vorschlag: str) -> str:
"""Ein Benutzername, den es noch nicht gibt."""
basis = "".join(c for c in (vorschlag or "").strip().lower()
if c.isalnum() or c in "._-")[:48] or "nutzer"
name = basis
n = 2
while db.query(User.id).filter(User.username == name).first() is not None:
name = f"{basis}{n}"[:64]
n += 1
return name
def _darf_anlegen(p, anmeldung: oidc_service.Anmeldung) -> bool:
if not p.auto_create:
return False
if not p.allowed_domains:
return True
if not anmeldung.email:
return False
return anmeldung.email.rsplit("@", 1)[-1] in p.allowed_domains
def _uebernimm_namen(db: Session, nutzer: User, anmeldung: oidc_service.Anmeldung) -> None:
"""Namensaenderungen beim Anbieter nachziehen.
Wichtig ist, WESSEN Name das jeweils ist:
``display_name`` ist reine Anzeige und gehoert dem Anbieter – heiratet
jemand, soll der neue Name auch hier stehen.
``username`` ist das, was beim Passwort-Login eingetippt wird. Bei einem
Konto MIT Passwort gehoert er dem Nutzer; ihn hinter dessen Ruecken zu
aendern hiesse, ihm seine Anmeldung wegzunehmen. Bei einem reinen
SSO-Konto gibt es nichts einzutippen – dort ist er ein Etikett, und der
Anbieter darf es bestimmen.
Dass das ueberhaupt geht, liegt daran, dass der Name NIRGENDS ein
Schluessel ist: Buchungen, Konten und Sitzungen haengen an der Nummer des
Nutzers. Umbenennen zieht nichts mit.
Ist der neue Name schon vergeben, bleibt der alte stehen. Eine Anmeldung
daran scheitern zu lassen waere die schlechtere Antwort.
"""
if anmeldung.name and anmeldung.name[:120] != nutzer.display_name:
nutzer.display_name = anmeldung.name[:120]
gewuenscht = (anmeldung.preferred_username or "").strip()[:64]
if not gewuenscht or gewuenscht == nutzer.username:
return
if nutzer.password_hash is not None:
return # gehoert dem Nutzer, nicht dem Anbieter
belegt = (
db.query(User.id)
.filter(User.username == gewuenscht, User.id != nutzer.id)
.first()
)
if belegt is None:
nutzer.username = gewuenscht
def _finde_oder_lege_an(db: Session, p, anmeldung: oidc_service.Anmeldung) -> User:
"""Zu wem gehoert dieser Anmeldende?
Zwei Faelle, und dazwischen ausdruecklich NICHTS:
1. **Bekannte Kennung** – die Verknuepfung gibt es schon. Eindeutig.
2. **Neu anlegen**, wenn der Anbieter das darf.
Was hier bewusst FEHLT, ist der bequeme dritte Fall: ueber die E-Mail ein
vorhandenes Konto suchen und stillschweigend verknuepfen. Wer beim Anbieter
eine fremde Adresse eintragen kann, uebernaehme damit ein fremdes Konto –
und die E-Mail ist die Angabe, die sich dort am leichtesten aendern laesst.
Ein bestehendes Konto wird ausdruecklich verknuepft, im angemeldeten
Zustand, ueber ``/link``.
"""
vorhanden = (
db.query(UserIdentity)
.filter(UserIdentity.provider == p.name,
UserIdentity.subject == anmeldung.subject)
.first()
)
if vorhanden is not None:
vorhanden.email = anmeldung.email
vorhanden.last_login_at = _now()
nutzer = db.get(User, vorhanden.user_id)
if nutzer is None:
raise oidc_service.OidcFehler("Das verknuepfte Konto gibt es nicht mehr.")
_uebernimm_namen(db, nutzer, anmeldung)
return nutzer
if not _darf_anlegen(p, anmeldung):
raise oidc_service.OidcFehler(
"Zu dieser Anmeldung gibt es kein Konto in Certain. Melde dich mit "
"Benutzername und Passwort an und verknuepfe dein Konto in den "
"Einstellungen."
)
nutzer = User(
username=_freier_benutzername(
db, anmeldung.preferred_username
or (anmeldung.email.split("@")[0] if anmeldung.email else "")),
# Kein Passwort: Dieses Konto meldet sich ueber den Anbieter an.
password_hash=None,
display_name=(anmeldung.name or "")[:120] or None,
role=Role.user,
is_active=True,
)
db.add(nutzer)
db.flush()
db.add(UserIdentity(user_id=nutzer.id, provider=p.name,
subject=anmeldung.subject, email=anmeldung.email,
last_login_at=_now()))
return nutzer
@router.get("/{provider}/callback", name="oidc_callback")
def oidc_callback(provider: str, request: Request, response: Response,
code: str | None = Query(default=None),
state: str | None = Query(default=None),
error: str | None = Query(default=None),
error_description: str | None = Query(default=None),
db: Session = Depends(get_db)):
"""Rueckkehr vom Anbieter."""
eintrag = (
db.query(OidcState).filter(OidcState.state == (state or "")).first()
if state else None
)
# Das Ziel steht im gemerkten Versuch; ohne ihn bleibt nur die Startseite.
weiter = _sicheres_ziel(eintrag.weiter if eintrag is not None else None)
if error:
return _fehlerseite(weiter, error_description or error)
if eintrag is None:
# Auch bei falschem state: Ohne Zuordnung waere jede Rueckkehr gueltig.
return _fehlerseite(weiter, "Diese Anmeldung ist abgelaufen oder gehoert "
"nicht hierher. Bitte noch einmal versuchen.")
verbraucht = eintrag # ab hier gilt er als benutzt, egal wie es ausgeht
abgelaufen = eintrag.expires_at.replace(tzinfo=timezone.utc) < _now() \
if eintrag.expires_at.tzinfo is None else eintrag.expires_at < _now()
nonce, verifier, ziel_uri = eintrag.nonce, eintrag.code_verifier, eintrag.redirect_uri
link_user_id = eintrag.link_user_id
db.delete(verbraucht)
db.commit()
if abgelaufen:
return _fehlerseite(weiter, "Die Anmeldung hat zu lange gedauert. "
"Bitte noch einmal versuchen.")
if eintrag.provider != provider:
return _fehlerseite(weiter, "Die Antwort gehoert zu einem anderen Anmeldedienst.")
if not code:
return _fehlerseite(weiter, "Der Anmeldedienst hat keinen Code geschickt.")
p = oidc_config.get(provider)
if p is None:
return _fehlerseite(weiter, f"Kein Anmeldedienst '{provider}' eingerichtet.")
try:
anmeldung = oidc_service.schliesse_ab(p, code, ziel_uri, verifier, nonce)
except oidc_service.OidcFehler as e:
return _fehlerseite(weiter, str(e))
# --- Verknuepfen: der Nutzer ist schon angemeldet -----------------------
if link_user_id is not None:
nutzer = db.get(User, link_user_id)
if nutzer is None:
return _fehlerseite(weiter, "Das Konto gibt es nicht mehr.")
schon = (
db.query(UserIdentity)
.filter(UserIdentity.provider == p.name,
UserIdentity.subject == anmeldung.subject)
.first()
)
if schon is not None and schon.user_id != nutzer.id:
return _fehlerseite(
weiter, "Dieses Konto beim Anmeldedienst gehoert bereits zu einem "
"anderen Benutzer in Certain.")
if schon is None:
db.add(UserIdentity(user_id=nutzer.id, provider=p.name,
subject=anmeldung.subject, email=anmeldung.email,
last_login_at=_now()))
db.commit()
from urllib.parse import quote
return RedirectResponse(f"{weiter}?oidc_linked={quote(p.name)}", status_code=303)
# --- Anmelden -----------------------------------------------------------
try:
nutzer = _finde_oder_lege_an(db, p, anmeldung)
except oidc_service.OidcFehler as e:
db.rollback()
return _fehlerseite(weiter, str(e))
if not nutzer.is_active:
db.rollback()
return _fehlerseite(weiter, "Dieses Konto ist deaktiviert.")
db.commit()
# Kein zweiter Faktor und kein Passwortwechsel: Beides gehoert zum
# Passwort-Login. Der Anbieter hat den Nutzer geprueft, und ein Passwort,
# das gewechselt werden muesste, gibt es hier womoeglich gar nicht.
raw = sessions.create_session(db, nutzer, request.headers.get("user-agent"))
antwort = RedirectResponse(weiter, status_code=303)
settings = get_settings()
antwort.set_cookie(
key=settings.cookie_name,
value=raw,
max_age=settings.session_ttl_days * 86400,
httponly=True,
secure=settings.cookie_secure,
samesite=settings.cookie_samesite,
path="/",
)
return antwort
@router.get("/identities")
def my_identities(db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Womit dieses Konto verknuepft ist – fuer die Kontoseite."""
bekannt = oidc_config.providers()
return {
"identities": [
{
"id": i.id,
"provider": i.provider,
"label": bekannt[i.provider].label if i.provider in bekannt else i.provider,
"email": i.email,
"last_login_at": i.last_login_at.isoformat() if i.last_login_at else None,
}
for i in db.query(UserIdentity)
.filter(UserIdentity.user_id == user.id)
.order_by(UserIdentity.id)
],
"hat_passwort": user.password_hash is not None,
}
@router.delete("/identities/{identity_id}", status_code=status.HTTP_204_NO_CONTENT)
def unlink(identity_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> None:
"""Eine Verknuepfung loesen.
Nicht die letzte, wenn es kein Passwort gibt: Sonst schliesst sich der
Nutzer aus seinem eigenen Konto aus, und niemand koennte ihn wieder
hereinlassen.
"""
eintrag = (
db.query(UserIdentity)
.filter(UserIdentity.id == identity_id, UserIdentity.user_id == user.id)
.first()
)
if eintrag is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="Verknuepfung nicht gefunden")
uebrig = (
db.query(UserIdentity)
.filter(UserIdentity.user_id == user.id, UserIdentity.id != identity_id)
.count()
)
if uebrig == 0 and user.password_hash is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=("Das ist die einzige Anmeldung fuer dieses Konto. Setz erst "
"ein Passwort, sonst kommst du nicht mehr herein."),
)
db.delete(eintrag)
db.commit()
+225
View File
@@ -0,0 +1,225 @@
"""Schnellerfassung für Geräte ohne Anmeldung (iOS-Kurzbefehle).
Der Kontoauszug kommt erst am Monatsende. Wer im Laden bezahlt hat, weiss in
dem Moment noch, wofür – auf dem Auszug steht später oft nur ein
nichtssagender Betreibername. Diese Endpunkte nehmen so eine Zahlung entgegen.
Die Anmeldung läuft NICHT über die Sitzung, sondern über eine Gerätemarke
(``require_quick_token``). Deren Geltungsbereich ist eng: erfassen ja, lesen
nein. Die einzige Ausnahme ist die Kontoliste, die der Kurzbefehl für seine
Auswahl braucht – sie liefert nur Name, Symbol und Farbe, keine Salden.
"""
from datetime import date as date_t
from decimal import ROUND_HALF_UP, Decimal
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import require_quick_token
from ..models import (
Account,
ApiToken,
InboxItem,
InboxSource,
InboxStatus,
Transaction,
PendingUpload,
TxnType,
User,
)
from ..schemas import InboxItemOut, QuickBookingIn, QuickBookingOut
from ..services import csv_import, currency, eingang
router = APIRouter(prefix="/quick", tags=["quick"])
def _konto(db: Session, marke: ApiToken, gewuenscht: int | None) -> Account:
"""Zielkonto bestimmen: ausdrücklich genannt, sonst das der Marke."""
konto_id = gewuenscht or marke.default_account_id
if konto_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Kein Konto angegeben und die Marke hat kein Standardkonto",
)
konto = db.get(Account, konto_id)
if konto is None or konto.owner_id != marke.owner_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Konto nicht gefunden")
return konto
@router.get("/accounts")
def quick_accounts(
marke: ApiToken = Depends(require_quick_token), db: Session = Depends(get_db)
) -> list[dict]:
"""Konten für die Auswahlliste im Kurzbefehl – ohne Salden."""
konten = (
db.query(Account)
.filter(Account.owner_id == marke.owner_id, Account.is_archived.is_(False))
.order_by(Account.position, Account.name)
.all()
)
return [
{"id": a.id, "name": a.name, "currency": a.currency, "icon": a.icon, "color": a.color,
"default": a.id == marke.default_account_id}
for a in konten
]
@router.post("/booking", response_model=QuickBookingOut, status_code=status.HTTP_201_CREATED)
def quick_booking(
payload: QuickBookingIn,
marke: ApiToken = Depends(require_quick_token),
db: Session = Depends(get_db),
) -> QuickBookingOut:
konto = _konto(db, marke, payload.account_id)
user = db.get(User, marke.owner_id)
tag = payload.date or date_t.today()
betrag = int(
(payload.amount.copy_abs() * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
)
if betrag == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Betrag ist null")
# Ein doppelt ausgelöster Kurzbefehl (schlechtes Netz, Doppeltipp) darf
# nicht zweimal buchen. Die bestehende Erfassung wird zurückgemeldet.
if payload.external_id:
schon = (
db.query(InboxItem)
.filter(InboxItem.owner_id == user.id, InboxItem.source == InboxSource.shortcut,
InboxItem.external_id == payload.external_id)
.first()
)
if schon is not None:
return QuickBookingOut(
message="Bereits erfasst", item=InboxItemOut.model_validate(schon)
)
# Durch die normale Erkennung schicken, damit Schlagwort- und Betragsregeln
# die Kategorie vorschlagen – dieselbe Strecke wie beim CSV-Import.
vorzeichen = -betrag if payload.kind == "expense" else betrag
erkannt = csv_import.detect_rows(db, user, konto, [{
"date": tag.isoformat(),
"amount_cents": vorzeichen,
"payee": payload.payee,
"purpose": payload.note,
}])
kategorie_id = erkannt[0].get("category_id") if erkannt else None
txn = Transaction(
owner_id=user.id,
type=TxnType.expense if payload.kind == "expense" else TxnType.income,
date=tag,
account_id=konto.id,
amount_cents=betrag,
amount_eur_cents=currency.compute_amount_eur_cents(db, betrag, konto.currency, tag),
category_id=kategorie_id,
payee=(payload.payee or None),
description=(payload.note or None),
# Die Kennung des Kurzbefehls gehoert auch an die Buchung, nicht nur an
# den Eingangs-Eintrag darueber: Nur dort ueberlebt sie, wenn der
# Eintrag einmal wegfaellt.
source_uid=eingang.quell_kennung(InboxSource.shortcut, payload.external_id),
)
db.add(txn)
db.flush()
eintrag = InboxItem(
owner_id=user.id,
source=InboxSource.shortcut,
status=InboxStatus.booked,
account_id=konto.id,
date=tag,
amount_cents=vorzeichen,
currency=konto.currency,
payee=(payload.payee or None),
note=(payload.note or None),
category_id=kategorie_id,
transaction_id=txn.id,
external_id=(payload.external_id or None),
)
db.add(eintrag)
db.commit()
db.refresh(eintrag)
wohin = f" · {payload.payee}" if payload.payee else ""
return QuickBookingOut(
message=f"{betrag / 100:.2f} {konto.currency} auf {konto.name}{wohin}",
item=InboxItemOut.model_validate(eintrag),
)
# Grosszuegig, aber nicht unbegrenzt: Ein Jahresauszug liegt weit darunter.
MAX_UPLOAD = 5 * 1024 * 1024
@router.post("/statement", status_code=status.HTTP_201_CREATED)
async def quick_statement(
file: UploadFile = File(...),
account_id: int | None = Form(None),
marke: ApiToken = Depends(require_quick_token),
db: Session = Depends(get_db),
) -> dict:
"""Kontoauszug vom Telefon entgegennehmen (Teilen-Menü).
Hier wird NICHT gebucht. Ein Auszug hat schnell hunderte Zeilen; die am
Telefon durchzusehen wäre keine Erleichterung. Die Datei wird abgelegt und
wartet in der Import-Ansicht – dort gibt es Vorschau, Saldo-Abgleich und
die Möglichkeit, einzelne Zeilen abzuwählen.
"""
daten = await file.read()
if not daten:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Datei ist leer")
if len(daten) > MAX_UPLOAD:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Datei zu gross ({len(daten) // 1024} KB, erlaubt sind "
f"{MAX_UPLOAD // 1024 // 1024} MB)",
)
# Ohne ausdrückliche Angabe das Konto aus der Datei ableiten – dann reicht
# EIN Kurzbefehl fürs Telefon statt einer je Konto.
if account_id is not None:
konto = _konto(db, marke, account_id)
herkunft = "angegeben"
else:
user = db.get(User, marke.owner_id)
erkannt = csv_import.erkenne_format(daten)
gefunden = csv_import.erkenne_konto(db, user, daten, erkannt)
if gefunden["account_id"] is None:
if marke.default_account_id:
konto = _konto(db, marke, marke.default_account_id)
herkunft = "Standardkonto des Schlüssels"
else:
namen = ", ".join(
f"{a.name} = {a.id}" for a in db.query(Account).filter(
Account.owner_id == marke.owner_id, Account.is_archived.is_(False))
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(f"{gefunden['grund']}. Bitte account_id im Kurzbefehl "
f"angeben. Deine Konten: {namen}"),
)
else:
konto = _konto(db, marke, gefunden["account_id"])
herkunft = gefunden["grund"]
upload = PendingUpload(
owner_id=marke.owner_id,
account_id=konto.id,
filename=(file.filename or "Vom Telefon")[:255],
content=daten,
size_bytes=len(daten),
)
db.add(upload)
db.commit()
db.refresh(upload)
return {
"id": upload.id,
"konto": konto.name,
"size_bytes": upload.size_bytes,
"erkannt_als": herkunft,
"message": f"Empfangen für {konto.name} ({herkunft}) – zum Prüfen in Certain öffnen",
}
+6
View File
@@ -20,6 +20,11 @@ BUDGET_START_DAY_KEY = "budget_start_day"
LOCALE_KEY = "locale" LOCALE_KEY = "locale"
THEME_KEY = "theme" THEME_KEY = "theme"
DATE_FORMAT_KEY = "date_format" DATE_FORMAT_KEY = "date_format"
# Welche Waehrungen in Auswahlfeldern erscheinen (Komma-getrennt).
# Beschraenkt nur die ANZEIGE: Gueltig bleibt alles, was einen
# EZB-Referenzkurs hat – sonst verloere ein Konto seine Waehrung,
# nur weil sie hier gerade nicht angehakt ist.
ACTIVE_CURRENCIES_KEY = "active_currencies"
def known_defaults() -> dict[str, str]: def known_defaults() -> dict[str, str]:
@@ -29,6 +34,7 @@ def known_defaults() -> dict[str, str]:
LOCALE_KEY: "de", LOCALE_KEY: "de",
THEME_KEY: "system", THEME_KEY: "system",
DATE_FORMAT_KEY: "de", DATE_FORMAT_KEY: "de",
ACTIVE_CURRENCIES_KEY: "EUR,CHF,USD",
} }
+346
View File
@@ -0,0 +1,346 @@
"""Abos und Verträge: wiederkehrende Zahlungen mit bekanntem Betrag.
Netflix, Handyvertrag, Krankenkasse, Halbtax – dieselbe Form, deshalb
derselbe Bereich. Hier wird nur verzeichnet und gerechnet; erkannt und
gebucht wird (noch) nichts.
Der Monatswert entsteht in ``Subscription.monatlich_cents``: Betrag geteilt
durch Intervall. Für die Summe am Kopf wird er zusätzlich in die Hauptwährung
umgerechnet – ein Vertrag in Euro und einer in Franken lassen sich sonst nicht
addieren.
"""
from datetime import date
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import (
Account,
Category,
CategoryKind,
Subscription,
SubscriptionLogo,
Transaction,
User,
)
from ..schemas import (
SubscriptionIn,
SubscriptionListOut,
SubscriptionOut,
SubscriptionSummary,
SubscriptionUpdate,
)
from ..services import abo_erkennung, currency, logo as logo_service
from ..services.csv_import import normalize_text
from .settings import BASE_CURRENCY_KEY, get_user_setting
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
def _base(db: Session, user: User) -> str:
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
def _pruefe_verweise(db: Session, user: User, daten: dict) -> None:
"""Konten und Kategorie müssen dem Nutzer gehören.
Die Kategorie muss ausserdem eine AUSGABE sein: Ein Vertrag kostet Geld,
und gegen eine Einnahme-Kategorie liesse er sich später nicht budgetieren.
"""
for feld in ("account_id", "savings_account_id"):
if daten.get(feld) is not None:
get_owned(db, Account, daten[feld], user)
if daten.get("category_id") is not None:
kategorie = get_owned(db, Category, daten["category_id"], user)
if kategorie.type != CategoryKind.expense:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(f"'{kategorie.name}' ist keine Ausgabe-Kategorie. Ein "
f"Vertrag kostet Geld."),
)
def _logo_stand(db: Session, sub_id: int) -> int | None:
"""Wann das Logo zuletzt geaendert wurde – oder None, wenn es keins gibt.
Bewusst NICHT das Bild: Die Liste laedt es einzeln nach, sonst haenge sie
voller Base64. Der Zeitstempel wandert in die Bild-Adresse und ueberholt
damit den Zwischenspeicher des Browsers; ohne ihn zeigte er nach dem
Austauschen weiter das alte Logo.
"""
row = (
db.query(SubscriptionLogo.updated_at)
.filter(SubscriptionLogo.subscription_id == sub_id)
.first()
)
if row is None:
return None
return int(row[0].timestamp()) if row[0] else 0
def to_out(db: Session, s: Subscription, base: str, heute: date) -> SubscriptionOut:
stand = _logo_stand(db, s.id)
monatlich = s.monatlich_cents
umgerechnet = (
monatlich if s.currency == base
else currency.convert(db, monatlich, s.currency, base, heute)
)
return SubscriptionOut(
**{k: getattr(s, k) for k in (
"id", "name", "icon", "color", "amount_cents", "currency",
"interval_months", "account_id", "category_id", "next_due",
"payee_pattern", "savings_account_id", "is_active", "cancelled_at",
"notes", "position")},
monatlich_cents=monatlich,
monatlich_base_cents=umgerechnet,
tage_bis_faellig=(s.next_due - heute).days if s.next_due else None,
has_logo=stand is not None,
logo_version=stand,
)
@router.get("", response_model=SubscriptionListOut)
def list_subscriptions(db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
base = _base(db, user)
heute = date.today()
zeilen = [
to_out(db, s, base, heute)
for s in db.query(Subscription)
.filter(Subscription.owner_id == user.id)
.order_by(Subscription.position, Subscription.name)
]
# Gekündigtes zählt nicht mit – es steht nur noch für den Rückblick da.
aktiv = [z for z in zeilen if z.is_active]
summe = sum(z.monatlich_base_cents or 0 for z in aktiv)
return SubscriptionListOut(
subscriptions=zeilen,
summary=SubscriptionSummary(
currency=base,
monatlich_cents=summe,
# Nicht monatlich × 12: Bei einem Jahresvertrag hat das Runden des
# Monatswerts sonst bis zu elf Rappen Fehler.
jaehrlich_cents=summe * 12,
anzahl_aktiv=len(aktiv),
ohne_kurs=sorted({
z.currency for z in aktiv if z.monatlich_base_cents is None
}),
),
)
@router.post("", response_model=SubscriptionOut, status_code=status.HTTP_201_CREATED)
def create_subscription(payload: SubscriptionIn, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
daten = payload.model_dump()
_pruefe_verweise(db, user, daten)
max_pos = (
db.query(Subscription.position)
.filter(Subscription.owner_id == user.id)
.order_by(Subscription.position.desc())
.first()
)
s = Subscription(owner_id=user.id, position=(max_pos[0] + 1) if max_pos else 0,
**daten)
s.name = s.name.strip()
# Ohne eigenes Muster der Name: Er ist der beste erste Versuch, und wer es
# besser weiss, traegt es ein.
if not s.payee_pattern:
s.payee_pattern = s.name
db.add(s)
db.commit()
db.refresh(s)
return to_out(db, s, _base(db, user), date.today())
# --- Zuordnung: welche Buchung gehoert zu welchem Vertrag --------------------
@router.post("/zuordnen")
def zuordnen(db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Buchungen ihren Vertraegen zuordnen.
Eindeutig Sicheres wird zugeordnet, der Rest kommt als Vorschlag zurueck.
Laeuft ueber ALLE noch nicht zugeordneten Buchungen, auch alte – wer seine
Vertraege nachtraegt, bekommt damit rueckwirkend seinen Verlauf.
"""
return abo_erkennung.finde(db, user)
@router.get("/vorschlaege")
def vorschlaege(db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Nur fragen, nichts veraendern – fuer eine Ansicht, die bloss hinsieht."""
return abo_erkennung.finde(db, user, nur_vorschlaege=True)
@router.post("/{sub_id}/buchungen/{txn_id}", response_model=SubscriptionOut)
def zuordnen_von_hand(sub_id: int, txn_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Eine Buchung diesem Vertrag zuordnen – einen Vorschlag annehmen."""
s = get_owned(db, Subscription, sub_id, user)
txn = get_owned(db, Transaction, txn_id, user)
txn.subscription_id = s.id
if txn.date is not None:
abo_erkennung._ruecke_faelligkeit(s, txn.date)
# Und daraus lernen: Musste der Nutzer von Hand zuordnen, hat der
# Erkennungstext nicht gepasst. Der Empfaenger dieser Buchung wird der
# neue – beim naechsten Mal faellt dieselbe Zahlung von selbst zu. Der
# Name bleibt als zweite Chance (siehe _passt_text).
#
# shortcut: ersetzt einen frueher gelernten Text, statt mehrere zu fuehren.
# Taucht ein Vertrag unter mehreren Empfaengernamen auf, braucht es eine
# Liste wie bei den Konto-Kennungen.
if txn.payee and not abo_erkennung._passt_text(s, normalize_text(txn.payee)):
s.payee_pattern = txn.payee[:255]
db.commit()
db.refresh(s)
return to_out(db, s, _base(db, user), date.today())
@router.delete("/buchungen/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
def zuordnung_loesen(txn_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> None:
"""Das Etikett abziehen. Die Buchung bleibt, wie sie ist."""
txn = get_owned(db, Transaction, txn_id, user)
txn.subscription_id = None
db.commit()
@router.get("/{sub_id}/verlauf")
def verlauf(sub_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Die erkannten Abbuchungen dieses Vertrags.
Beantwortet "was habe ich dafuer insgesamt bezahlt" – und zeigt, wann der
Preis gestiegen ist.
"""
s = get_owned(db, Subscription, sub_id, user)
zeilen = abo_erkennung.verlauf(db, user, s)
return {
"buchungen": zeilen,
"anzahl": len(zeilen),
"summe_cents": sum(z["amount_cents"] for z in zeilen),
"currency": zeilen[0]["currency"] if zeilen else s.currency,
}
@router.post("/{sub_id}/preis", response_model=SubscriptionOut)
def preis_uebernehmen(sub_id: int, amount_cents: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Einen geaenderten Preis uebernehmen.
Eigener Schritt und nicht automatisch: Eine Preiserhoehung ist eine
Nachricht an den Nutzer, kein Wartungsvorgang. Wer sie stillschweigend
nachzieht, nimmt ihm die einzige Gelegenheit, sie zu bemerken.
"""
if amount_cents < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Betrag darf nicht negativ sein")
s = get_owned(db, Subscription, sub_id, user)
s.amount_cents = amount_cents
db.commit()
db.refresh(s)
return to_out(db, s, _base(db, user), date.today())
@router.get("/{sub_id}", response_model=SubscriptionOut)
def get_subscription(sub_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
return to_out(db, get_owned(db, Subscription, sub_id, user),
_base(db, user), date.today())
@router.patch("/{sub_id}", response_model=SubscriptionOut)
def update_subscription(sub_id: int, payload: SubscriptionUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
s = get_owned(db, Subscription, sub_id, user)
daten = payload.model_dump(exclude_unset=True)
_pruefe_verweise(db, user, daten)
for key, value in daten.items():
# Diese Felder muessen sich LEEREN lassen – sonst wird man ein einmal
# gesetztes Sparkonto oder Faelligkeitsdatum nie wieder los.
leerbar = key in ("account_id", "category_id", "next_due",
"savings_account_id", "cancelled_at", "notes",
"payee_pattern")
if value is not None or leerbar:
setattr(s, key, value)
if s.name:
s.name = s.name.strip()
db.commit()
db.refresh(s)
return to_out(db, s, _base(db, user), date.today())
@router.delete("/{sub_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_subscription(sub_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
db.delete(get_owned(db, Subscription, sub_id, user))
db.commit()
# --- Markenlogo -------------------------------------------------------------
@router.put("/{sub_id}/logo", response_model=SubscriptionOut)
async def upload_logo(sub_id: int, file: UploadFile = File(...),
db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Ein Logo hochladen. Ersetzt ein vorhandenes.
Am Logo findet man den Vertrag schneller als am Namen – Netflix erkennt
man am roten N, nicht am Wort.
"""
s = get_owned(db, Subscription, sub_id, user)
try:
klein = logo_service.mach_logo(await file.read())
except logo_service.LogoFehler as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
vorhanden = db.get(SubscriptionLogo, s.id)
if vorhanden is None:
db.add(SubscriptionLogo(subscription_id=s.id, content=klein,
size_bytes=len(klein)))
else:
vorhanden.content = klein
vorhanden.size_bytes = len(klein)
db.commit()
return to_out(db, s, _base(db, user), date.today())
@router.get("/{sub_id}/logo")
def get_logo(sub_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
# Ueber get_owned, nicht ueber die Logo-Tabelle direkt: Sonst laege das
# Bild eines fremden Vertrags hinter einer geratenen Nummer offen.
s = get_owned(db, Subscription, sub_id, user)
row = db.get(SubscriptionLogo, s.id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kein Logo")
return Response(
content=row.content,
media_type=logo_service.MIME,
headers={
# Kurz, aber nicht null: Die Liste fragt es je Zeile ab. Beim
# Austauschen haengt die Fassung in der Adresse (?v=...), das
# ueberholt den Zwischenspeicher.
"Cache-Control": "private, max-age=300",
},
)
@router.delete("/{sub_id}/logo", response_model=SubscriptionOut)
def delete_logo(sub_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
s = get_owned(db, Subscription, sub_id, user)
row = db.get(SubscriptionLogo, s.id)
if row is not None:
db.delete(row)
db.commit()
return to_out(db, s, _base(db, user), date.today())
+83
View File
@@ -0,0 +1,83 @@
"""Tags: frei vergebene Etiketten fuer Buchungen. Alles owner-gescoped.
Neben der Kategorie, nicht statt ihr. Die Kategorie sagt, wofuer das Geld war;
ein Tag alles, was quer dazu liegt – und davon duerfen mehrere an einer Buchung
haengen.
Die Zuordnung selbst wird hier NICHT gesetzt, sondern an der Buchung
(``PATCH /transactions/{id}`` mit ``tag_ids``). Sonst gaebe es zwei Wege, eine
Buchung zu aendern, und zwei Stellen, an denen man es vergessen kann.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Tag, User
from ..schemas import TagCreate, TagOut, TagUpdate
router = APIRouter(prefix="/tags", tags=["tags"])
def _name_frei(db: Session, user: User, name: str, ausser_id: int | None = None) -> None:
"""Denselben Namen zweimal gaebe es in der Liste nicht zu unterscheiden."""
q = db.query(Tag.id).filter(Tag.owner_id == user.id, Tag.name == name)
if ausser_id is not None:
q = q.filter(Tag.id != ausser_id)
if q.first() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT,
detail=f"Den Tag „{name}“ gibt es schon.")
@router.get("", response_model=list[TagOut])
def list_tags(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
# Alphabetisch: In einer Auswahlliste sucht man nach dem Namen, nicht
# danach, wann der Tag entstanden ist.
return (
db.query(Tag).filter(Tag.owner_id == user.id).order_by(Tag.name).all()
)
@router.post("", response_model=TagOut, status_code=status.HTTP_201_CREATED)
def create_tag(payload: TagCreate, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
name = payload.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Leerer Name")
_name_frei(db, user, name)
tag = Tag(owner_id=user.id, name=name, color=payload.color)
db.add(tag)
db.commit()
db.refresh(tag)
return TagOut.model_validate(tag)
@router.patch("/{tag_id}", response_model=TagOut)
def update_tag(tag_id: int, payload: TagUpdate, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
tag = get_owned(db, Tag, tag_id, user)
daten = payload.model_dump(exclude_unset=True)
if "name" in daten and daten["name"] is not None:
name = daten["name"].strip()
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Leerer Name")
_name_frei(db, user, name, ausser_id=tag.id)
tag.name = name
if daten.get("color") is not None:
tag.color = daten["color"]
db.commit()
db.refresh(tag)
return TagOut.model_validate(tag)
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_tag(tag_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Den Tag loeschen. Die Buchungen bleiben – es faellt nur das Etikett ab
(ON DELETE CASCADE auf ``transaction_tags``)."""
tag = get_owned(db, Tag, tag_id, user)
db.delete(tag)
db.commit()
+348 -36
View File
@@ -8,41 +8,84 @@ Kurs (``fx_rate``) hat Vorrang vor dem EZB-Kurs.
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import or_, select, union
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, get_owned from ..deps import get_current_user, get_owned
from ..models import Account, Category, Transaction, TxnType, User from ..models import (
Account,
Category,
InboxItem,
InboxStatus,
Tag,
Transaction,
TransactionTag,
TransferLink,
TxnType,
User,
)
from ..schemas import TransactionCreate, TransactionOut, TransactionUpdate from ..schemas import TransactionCreate, TransactionOut, TransactionUpdate
from ..services import currency from ..services import buchung, budget, currency, uebertrag_buchen
from ..services.uebertrag_buchen import UebertragFehler
from .accounts import base_currency from .accounts import base_currency
router = APIRouter(prefix="/transactions", tags=["transactions"]) router = APIRouter(prefix="/transactions", tags=["transactions"])
def _fx(txn: Transaction) -> float | None: def _herkunft(db: Session, user: User, txns: list[Transaction]) -> dict[int, str]:
return float(txn.fx_rate) if txn.fx_rate is not None else None """Woher jede Buchung stammt.
Der Eingangs-Eintrag sagt es für alles, was über Bank, Mail oder Kurzbefehl
kam; sonst entscheidet der Import-Stapel: mit ihm aus einer Datei, ohne ihn
von Hand. Als Sammelabfrage, nicht je Zeile – die Liste geht bis 500.
"""
ids = [t.id for t in txns]
if not ids:
return {}
aus_eingang: dict[int, str] = {}
for txn_id, quelle in db.query(InboxItem.transaction_id, InboxItem.source).filter(
InboxItem.owner_id == user.id, InboxItem.transaction_id.in_(ids)
):
# Bei einem Übertrag zeigen ZWEI Einträge auf dieselbe Buchung. Die
# Gegenseite hat Certain selbst angelegt; gemeint ist die echte Quelle.
if txn_id in aus_eingang and quelle.value == "transfer":
continue
aus_eingang[txn_id] = quelle.value
return {
t.id: aus_eingang.get(t.id, "csv" if t.import_batch_id is not None else "manuell")
for t in txns
}
def _recompute(db: Session, txn: Transaction, from_acc: Account, to_acc: Account | None) -> None: def _pruefe_zeitraum(db: Session, user: User, label: str | None, tag: date) -> str | None:
"""Setzt amount_eur_cents und (bei Transfers) to_amount_cents neu.""" """Einen von Hand gewaehlten Budget-Monat pruefen.
txn.amount_eur_cents = currency.compute_amount_eur_cents(
db, txn.amount_cents, from_acc.currency, txn.date, _fx(txn) ``None`` nimmt die Wahl zurueck – dann entscheidet wieder das Datum. Sonst
) muss der Monat in Reichweite des Datums liegen: Ein Vertipper wie 2019-03
if txn.type == TxnType.transfer and to_acc is not None: liesse die Buchung aus jeder Auswertung verschwinden, ohne dass irgendwo
if txn.to_amount_cents is None: etwas fehlt. Drei Monate in jede Richtung decken den echten Fall ab (eine
if to_acc.currency == from_acc.currency: Zahlung kurz vor oder nach dem Stichtag, eine verspaetete Rechnung).
txn.to_amount_cents = txn.amount_cents """
else: if label is None:
conv = currency.convert( return None
db, txn.amount_cents, from_acc.currency, to_acc.currency, txn.date, _fx(txn) eigen = budget.period_of(tag, budget.start_tag(db, user))
) erlaubt = {budget.shift_label(eigen, n) for n in range(-3, 4)}
txn.to_amount_cents = conv if conv is not None else txn.amount_cents if label not in erlaubt:
else: raise HTTPException(
txn.to_amount_cents = None status.HTTP_400_BAD_REQUEST,
f"Budget-Monat {label} liegt zu weit von der Buchung entfernt "
f"(erlaubt: {min(erlaubt)} bis {max(erlaubt)})",
)
# Der abgeleitete Monat wird nicht festgeschrieben: Verschiebt sich das
# Datum spaeter, soll die Buchung wieder von selbst mitwandern.
return None if label == eigen else label
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str]) -> TransactionOut: def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str],
herkunft: dict[int, str] | None = None, start_tag: int = 1,
tags: dict[int, list[int]] | None = None) -> TransactionOut:
acc_ccy = ccy_by_account.get(txn.account_id, "EUR") acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
if base == "EUR": if base == "EUR":
amount_base = txn.amount_eur_cents amount_base = txn.amount_eur_cents
@@ -59,18 +102,64 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s
account_currency=acc_ccy, account_currency=acc_ccy,
amount_cents=txn.amount_cents, amount_cents=txn.amount_cents,
to_amount_cents=txn.to_amount_cents, to_amount_cents=txn.to_amount_cents,
fx_rate=_fx(txn), fx_rate=buchung.fx(txn),
amount_eur_cents=txn.amount_eur_cents, amount_eur_cents=txn.amount_eur_cents,
amount_base_cents=amount_base, amount_base_cents=amount_base,
category_id=txn.category_id, category_id=txn.category_id,
budget_treatment=txn.budget_treatment, budget_treatment=txn.budget_treatment,
budget_category_id=txn.budget_category_id, budget_category_id=txn.budget_category_id,
subscription_id=txn.subscription_id,
budget_period=txn.budget_period or budget.period_of(txn.date, start_tag),
budget_period_manuell=txn.budget_period is not None,
import_batch_id=txn.import_batch_id,
herkunft=(herkunft or {}).get(txn.id, "manuell"),
payee=txn.payee, payee=txn.payee,
description=txn.description, description=txn.description,
notes=txn.notes, notes=txn.notes,
tag_ids=(tags or {}).get(txn.id, []),
) )
def _tags(db: Session, user: User, txns: list[Transaction]) -> dict[int, list[int]]:
"""Die Tags je Buchung – als EINE Abfrage, nicht je Zeile.
Die Buchungsliste geht bis 5000 Zeilen; je Zeile zu fragen waere genau das
N+1, das die vier Nachbarn hier schon vermeiden.
"""
ids = [t.id for t in txns]
if not ids:
return {}
nach: dict[int, list[int]] = {}
for txn_id, tag_id in db.query(
TransactionTag.transaction_id, TransactionTag.tag_id
).filter(TransactionTag.owner_id == user.id,
TransactionTag.transaction_id.in_(ids)):
nach.setdefault(txn_id, []).append(tag_id)
return nach
def _setze_tags(db: Session, user: User, txn: Transaction, tag_ids: list[int]) -> None:
"""Die Tags einer Buchung auf genau diese Liste bringen.
Ersetzen statt ergaenzen: Die Oberflaeche schickt immer den vollen Stand,
sonst liesse sich ein Tag nie wieder abnehmen. Jede ID wird geprueft – ein
fremder Tag darf auch dann nicht haengenbleiben, wenn jemand die ID raet.
"""
gewuenscht = {int(i) for i in tag_ids}
for tag_id in gewuenscht:
get_owned(db, Tag, tag_id, user)
vorhanden = {
z.tag_id: z for z in db.query(TransactionTag).filter(
TransactionTag.owner_id == user.id,
TransactionTag.transaction_id == txn.id)
}
for tag_id, zeile in vorhanden.items():
if tag_id not in gewuenscht:
db.delete(zeile)
for tag_id in gewuenscht - set(vorhanden):
db.add(TransactionTag(owner_id=user.id, transaction_id=txn.id, tag_id=tag_id))
def _currency_map(db: Session, user: User) -> dict[int, str]: def _currency_map(db: Session, user: User) -> dict[int, str]:
rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all() rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all()
return {row[0]: row[1] for row in rows} return {row[0]: row[1] for row in rows}
@@ -81,35 +170,200 @@ def _validate_category(db: Session, user: User, category_id: int | None) -> None
get_owned(db, Category, category_id, user) get_owned(db, Category, category_id, user)
@router.get("/links")
def transaction_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
"""Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist.
Getrennt vom Buchungs-Schema, damit dieses unverändert bleibt: Die
Verknüpfung ist eine Schicht obendrauf, kein Feld der Buchung.
"""
from ..services import uebertraege
return {str(k): v for k, v in uebertraege.linkliste(db, user).items()}
@router.get("", response_model=list[TransactionOut]) @router.get("", response_model=list[TransactionOut])
def list_transactions( def list_transactions(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
account_id: int | None = None, account_id: int | None = None,
category_id: int | None = None, category_id: int | None = None,
# Zum Abarbeiten: nur Buchungen, die noch keine Kategorie haben. Überträge
# bleiben aussen vor – sie ordnen kein Geld ein, sie verschieben es nur.
uncategorized: bool = False,
type: TxnType | None = None, type: TxnType | None = None,
date_from: date | None = None, date_from: date | None = None,
date_to: date | None = None, date_to: date | None = None,
limit: int = Query(default=200, le=1000), # Suche und die zwei Filter, die es bisher nur im Browser gab. Die
# Weboberfläche lädt alle Buchungen und siebt sie dort; ein Telefon soll
# dafür nicht 5000 Zeilen über die Leitung ziehen müssen.
q: str | None = None,
tag_id: int | None = None,
subscription_id: int | None = None,
# Großzügiges Limit: die Buchungsliste (DataTable) filtert/sortiert im Browser,
# deshalb sollen standardmäßig ALLE Buchungen geladen werden, nicht nur 200.
limit: int = Query(default=5000, le=50000),
offset: int = 0, offset: int = 0,
): ):
q = db.query(Transaction).filter(Transaction.owner_id == user.id) abfrage = db.query(Transaction).filter(Transaction.owner_id == user.id)
if account_id is not None: if account_id is not None:
q = q.filter( abfrage = abfrage.filter(
(Transaction.account_id == account_id) | (Transaction.to_account_id == account_id) (Transaction.account_id == account_id) | (Transaction.to_account_id == account_id)
) )
if category_id is not None: if category_id is not None:
q = q.filter(Transaction.category_id == category_id) abfrage = abfrage.filter(Transaction.category_id == category_id)
if uncategorized:
# Verknüpfte Buchungen sind erledigt: Sie gehören zu einem Übertrag und
# zählen in keiner Auswertung. Sie hier nach einer Kategorie zu fragen,
# verlangt Arbeit, die nichts bewirkt.
verknuepft = union(
select(TransferLink.from_txn_id).where(TransferLink.owner_id == user.id),
select(TransferLink.to_txn_id).where(TransferLink.owner_id == user.id),
)
abfrage = abfrage.filter(Transaction.category_id.is_(None),
Transaction.type != TxnType.transfer,
Transaction.id.notin_(verknuepft))
if type is not None: if type is not None:
q = q.filter(Transaction.type == type) abfrage = abfrage.filter(Transaction.type == type)
if date_from is not None: if date_from is not None:
q = q.filter(Transaction.date >= date_from) abfrage = abfrage.filter(Transaction.date >= date_from)
if date_to is not None: if date_to is not None:
q = q.filter(Transaction.date <= date_to) abfrage = abfrage.filter(Transaction.date <= date_to)
rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all() if q:
# LIKE statt Volltextindex: Ein Haushalt bleibt im Zehntausenderbereich,
# da kostet die Suche nichts Spürbares. Was SQLite dabei nicht kann:
# Umlaute gross/klein gleichsetzen – „Müller" findet „müller" nicht.
# Die Prozent- und Unterstrich-Zeichen des Suchtexts werden entwertet,
# sonst wäre jede Eingabe mit „%" ein Platzhalter statt eines Zeichens.
muster = "%" + q.translate(
str.maketrans({"\\": "\\\\", "%": "\\%", "_": "\\_"})
) + "%"
abfrage = abfrage.filter(or_(
Transaction.payee.ilike(muster, escape="\\"),
Transaction.description.ilike(muster, escape="\\"),
Transaction.notes.ilike(muster, escape="\\"),
))
if tag_id is not None:
# Unterabfrage statt Join, wie im uncategorized-Zweig: Ein Join würde
# jede Buchung so oft liefern, wie sie Tags trägt.
mit_tag = select(TransactionTag.transaction_id).where(
TransactionTag.owner_id == user.id, TransactionTag.tag_id == tag_id
)
abfrage = abfrage.filter(Transaction.id.in_(mit_tag))
if subscription_id is not None:
abfrage = abfrage.filter(Transaction.subscription_id == subscription_id)
rows = abfrage.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
base = base_currency(db, user) base = base_currency(db, user)
ccy = _currency_map(db, user) ccy = _currency_map(db, user)
return [to_out(db, t, base, ccy) for t in rows] woher = _herkunft(db, user, rows)
tag = budget.start_tag(db, user)
etiketten = _tags(db, user, rows)
return [to_out(db, t, base, ccy, woher, tag, etiketten) for t in rows]
@router.get("/dubletten")
def duplicates(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
"""Buchungen, die gleich aussehen: gleiches Konto, gleicher Tag, gleicher
Betrag, gleicher Text.
Entschieden wird hier NICHTS. Drei Aufrundungen über 1.00 am selben Tag
sind echt, drei gleiche Einkäufe können es auch sein – das weiss nur der
Nutzer. Deshalb steht zu jeder Zeile die Kennung der Bank dabei: Tragen
zwei Zeilen dieselbe, ist es sicher dieselbe Zahlung; tragen sie
verschiedene, hat die Bank sie als verschieden gemeldet.
"""
from ..services.csv_import import normalize_text
gruppen: dict[tuple, list] = {}
texte: dict[int, str] = {}
for txn in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.type != TxnType.transfer,
).order_by(Transaction.date.desc(), Transaction.id):
# Konto, Tag und Betrag muessen immer stimmen. Der Text NICHT: Ein
# krummer Betrag wie 10.43 kommt an einem Tag kaum zweimal vor, auch
# wenn zwei Quellen ihn verschieden benennen – und genau daran
# scheitert ein Vergleich ueber den Text.
gruppen.setdefault((txn.account_id, txn.type.value, txn.date,
txn.amount_cents), []).append(txn)
texte[txn.id] = normalize_text(txn.payee or txn.description or "")
# Bei einem runden Betrag ist zweimal am selben Tag durchaus moeglich –
# dort muss auch der Text uebereinstimmen, sonst waere es blosser Zufall.
def rund(cents: int) -> bool:
return cents % 50 == 0
verdaechtig = {}
for schluessel, zeilen in gruppen.items():
if len(zeilen) < 2:
continue
if rund(schluessel[3]) and len({texte[t.id] for t in zeilen}) > 1:
continue
# Schon entschieden. Nicht die einzelne Zeile ausblenden, sondern die
# Gruppe erst, wenn ALLE ihren Haken tragen: Kommt spaeter eine dritte
# gleich aussehende dazu, ist die Frage wieder offen – und zwar fuer
# alle drei.
if all(t.duplicate_ok for t in zeilen):
continue
verdaechtig[schluessel] = zeilen
if not verdaechtig:
return {"gruppen": []}
alle_ids = [t.id for zeilen in verdaechtig.values() for t in zeilen]
herkunft = _herkunft(db, user, [t for zeilen in verdaechtig.values() for t in zeilen])
kennungen = {
txn_id: (wert or "")
for txn_id, wert in db.query(InboxItem.transaction_id, InboxItem.external_id)
.filter(InboxItem.owner_id == user.id, InboxItem.transaction_id.in_(alle_ids))
}
# Melden nur, wenn es etwas zu entscheiden gibt: Stammen alle Zeilen aus
# DEMSELBEN Bankkonto und tragen verschiedene Kennungen, hat die Bank sie
# ausdruecklich als verschieden gemeldet – so sind Revolut-Aufrundungen
# ueber denselben Betrag, und die sind echt.
def entschieden(zeilen) -> bool:
werte = [kennungen.get(t.id) for t in zeilen]
if any(w is None for w in werte):
return False
konten = {w.split(":", 1)[0] for w in werte}
return len(konten) == 1 and len(set(werte)) == len(werte)
verdaechtig = {k: v for k, v in verdaechtig.items() if not entschieden(v)}
if not verdaechtig:
return {"gruppen": []}
return {"gruppen": [
{
"account_id": k[0], "datum": k[2].isoformat(), "amount_cents": k[3],
"typ": k[1],
"zeilen": [
{"id": t.id, "payee": t.payee, "description": t.description,
"category_id": t.category_id, "herkunft": herkunft.get(t.id, "manuell"),
"kennung": (kennungen.get(t.id) or "").split(":", 1)[-1] or None}
for t in zeilen
],
}
for k, zeilen in sorted(verdaechtig.items(), key=lambda p: p[0][2], reverse=True)
][:100]}
class BehaltenIn(BaseModel):
ids: list[int]
@router.post("/dubletten/behalten", status_code=status.HTTP_204_NO_CONTENT)
def dubletten_behalten(
payload: BehaltenIn, db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
"""Beide behalten – die Frage ist beantwortet.
An den Buchungen aendert sich nichts. Ohne diesen Weg blieb nur Loeschen,
um die Meldung loszuwerden: Die Liste draengte zu einer Entscheidung, die
sie selbst ausdruecklich nicht treffen will.
"""
for txn_id in payload.ids:
get_owned(db, Transaction, txn_id, user).duplicate_ok = True
db.commit()
@router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED)
@@ -145,15 +399,21 @@ def create_transaction(
category_id=category_id, category_id=category_id,
budget_treatment=payload.budget_treatment, budget_treatment=payload.budget_treatment,
budget_category_id=payload.budget_category_id, budget_category_id=payload.budget_category_id,
budget_period=_pruefe_zeitraum(db, user, payload.budget_period, payload.date),
payee=(payload.payee or None), payee=(payload.payee or None),
description=(payload.description or None), description=(payload.description or None),
notes=(payload.notes or None), notes=(payload.notes or None),
) )
_recompute(db, txn, from_acc, to_acc) buchung.setze_betraege(db, txn, from_acc, to_acc)
db.add(txn) db.add(txn)
if payload.tag_ids:
db.flush() # erst mit id laesst sich das Etikett haengen
_setze_tags(db, user, txn, payload.tag_ids)
db.commit() db.commit()
db.refresh(txn) db.refresh(txn)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user)) return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
@router.get("/{txn_id}", response_model=TransactionOut) @router.get("/{txn_id}", response_model=TransactionOut)
@@ -161,7 +421,9 @@ def get_transaction(
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
): ):
txn = get_owned(db, Transaction, txn_id, user) txn = get_owned(db, Transaction, txn_id, user)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user)) return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
@router.patch("/{txn_id}", response_model=TransactionOut) @router.patch("/{txn_id}", response_model=TransactionOut)
@@ -192,6 +454,9 @@ def update_transaction(
if "budget_category_id" in data: if "budget_category_id" in data:
_validate_category(db, user, data["budget_category_id"]) _validate_category(db, user, data["budget_category_id"])
txn.budget_category_id = data["budget_category_id"] txn.budget_category_id = data["budget_category_id"]
if "budget_period" in data:
txn.budget_period = _pruefe_zeitraum(db, user, data["budget_period"],
data.get("date", txn.date))
# Konsistenz je nach Typ + Neuberechnung der abgeleiteten Werte. # Konsistenz je nach Typ + Neuberechnung der abgeleiteten Werte.
from_acc = get_owned(db, Account, txn.account_id, user) from_acc = get_owned(db, Account, txn.account_id, user)
@@ -203,11 +468,50 @@ def update_transaction(
txn.category_id = None txn.category_id = None
else: else:
txn.to_account_id = None txn.to_account_id = None
_recompute(db, txn, from_acc, to_acc) buchung.setze_betraege(db, txn, from_acc, to_acc)
# Nach exclude_unset: mitgeschickt heisst ersetzen (auch mit leerer Liste),
# weggelassen heisst unberuehrt lassen.
if data.get("tag_ids") is not None:
_setze_tags(db, user, txn, data["tag_ids"])
db.commit() db.commit()
db.refresh(txn) db.refresh(txn)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user)) return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
class TransactionTransferIn(BaseModel):
to_account_id: int
# Nur setzen, wenn man es besser weiss als Certain – siehe uebertrag_buchen.
trotzdem: bool = False
@router.post("/{txn_id}/transfer", response_model=TransactionOut)
def make_transfer(txn_id: int, payload: TransactionTransferIn,
db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Eine gebuchte Ausgabe oder Einnahme zum Kontoübertrag machen.
Nicht über PATCH: Der dreht die Richtung bei einer Einnahme nicht um und
legt keine Gegenseite auf dem Zielkonto an. Beides gehört zusammen, deshalb
läuft es durch denselben Dienst wie der Weg aus dem Eingang.
"""
txn = get_owned(db, Transaction, txn_id, user)
ziel = get_owned(db, Account, payload.to_account_id, user)
try:
uebertrag_buchen.aus_buchung(db, user, txn, ziel, payload.trotzdem)
except UebertragFehler as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT if e.nachfragen
else status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
db.commit()
db.refresh(txn)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -215,5 +519,13 @@ def delete_transaction(
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
): ):
txn = get_owned(db, Transaction, txn_id, user) txn = get_owned(db, Transaction, txn_id, user)
# Eingangs-Eintraege, die auf diese Buchung zeigen, mit verwerfen. Der
# Fremdschluessel setzt sonst nur ihre Verweisspalte auf NULL, und sie
# blieben als "gebucht" ohne Buchung stehen – bei einem Uebertrag sind das
# zwei, die Quelle und die Gegenseite.
for eintrag in db.query(InboxItem).filter(InboxItem.owner_id == user.id,
InboxItem.transaction_id == txn.id):
eintrag.status = InboxStatus.discarded
eintrag.transaction_id = None
db.delete(txn) db.delete(txn)
db.commit() db.commit()
+173
View File
@@ -0,0 +1,173 @@
"""Überträge zwischen eigenen Konten: suchen, verbinden, lösen.
Alle Wege hier ändern ausschliesslich die Verknüpfungs-Schicht. Keine Buchung
wird angefasst – das ist die tragende Eigenschaft des Entwurfs, nicht eine
Bequemlichkeit.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Account, LinkIgnored, Transaction, TransferLink, TxnType, User
from ..services import uebertraege
router = APIRouter(prefix="/uebertraege", tags=["uebertraege"])
class PaarIn(BaseModel):
a_id: int
b_id: int
def _paar(db: Session, user: User, payload: PaarIn) -> tuple[Transaction, Transaction]:
a = get_owned(db, Transaction, payload.a_id, user)
b = get_owned(db, Transaction, payload.b_id, user)
if a.id == b.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Eine Buchung kann nicht mit sich selbst verknüpft werden")
return a, b
@router.get("")
def list_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
return [
{"id": l.id, "from_txn_id": l.from_txn_id, "to_txn_id": l.to_txn_id,
"automatisch": l.automatisch}
for l in db.query(TransferLink).filter(TransferLink.owner_id == user.id)
.order_by(TransferLink.id)
]
@router.get("/vorschlaege")
def vorschlaege(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
"""Nur fragen, nichts tun.
Getrennt von ``/suchen``: Eine Ansicht, die bloss „ist das ein Übertrag?"
anzeigt, darf beim Hinsehen nichts verknüpfen.
"""
return uebertraege.finde(db, user, nur_vorschlaege=True)
@router.post("/suchen")
def suchen(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
"""Paare suchen. Eindeutige mit Übertrags-Hinweis werden verknüpft, der
Rest kommt als Vorschlag zurück."""
return uebertraege.finde(db, user)
@router.post("/verbinden")
def verbinden(
payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> dict:
a, b = _paar(db, user, payload)
if a.account_id == b.account_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Beide Buchungen gehören zum selben Konto")
if {a.type, b.type} != {TxnType.expense, TxnType.income}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Ein Übertrag braucht einen Abgang und einen Zugang")
schon = (
db.query(TransferLink)
.filter(TransferLink.owner_id == user.id)
.filter((TransferLink.from_txn_id.in_((a.id, b.id)))
| (TransferLink.to_txn_id.in_((a.id, b.id))))
.first()
)
if schon is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Eine der Buchungen ist bereits verknüpft")
link = uebertraege.verbinde(db, user, a, b, automatisch=False)
db.commit()
return {"id": link.id, "from_txn_id": link.from_txn_id, "to_txn_id": link.to_txn_id,
"automatisch": False}
@router.get("/ignoriert")
def ignorierte(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
"""Welche Vorschlaege wurden abgelehnt?
Ohne diese Liste war „Ignorieren" eine Einbahnstrasse: Das Paar blieb bei
jedem weiteren Suchlauf stumm, und nichts verriet, dass es je einen
Vorschlag gab. Wer einmal falsch klickte, suchte danach einen Fehler in
der Erkennung, den es nicht gab.
"""
eintraege = (
db.query(LinkIgnored)
.filter(LinkIgnored.owner_id == user.id)
.order_by(LinkIgnored.id.desc())
.all()
)
ids = {i for e in eintraege for i in (e.a_txn_id, e.b_txn_id)}
buchungen = {
t.id: t for t in db.query(Transaction).filter(Transaction.id.in_(ids or [0]))
}
namen = dict(db.query(Account.id, Account.name).filter(Account.owner_id == user.id))
def kurz(txn_id: int) -> dict | None:
t = buchungen.get(txn_id)
if t is None:
return None
return {
"id": t.id, "date": t.date.isoformat(), "amount_cents": t.amount_cents,
"payee": t.payee or t.description,
"account_name": namen.get(t.account_id, "?"),
}
ergebnis = []
for e in eintraege:
a, b = kurz(e.a_txn_id), kurz(e.b_txn_id)
# Ist eine der beiden Buchungen geloescht, gibt es nichts mehr
# zurueckzunehmen – der Eintrag waere nur noch Rauschen.
if a and b:
ergebnis.append({"a": a, "b": b})
return ergebnis
@router.delete("/ignoriert", status_code=status.HTTP_204_NO_CONTENT)
def ignorieren_aufheben(
payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> None:
"""Die Ablehnung zuruecknehmen – das Paar wird wieder vorgeschlagen."""
a, b = _paar(db, user, payload)
klein, gross = sorted((a.id, b.id))
(
db.query(LinkIgnored)
.filter(LinkIgnored.owner_id == user.id,
LinkIgnored.a_txn_id == klein, LinkIgnored.b_txn_id == gross)
.delete()
)
db.commit()
# Vor der Route mit dem Platzhalter, sonst verschluckt "/{link_id}" den
# wortwoertlichen Pfad: FastAPI nimmt die erste passende Route, und
# "ignoriert" laesst sich nicht als Zahl lesen (422 statt 204).
@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
def loesen(link_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Verknüpfung entfernen. Die beiden Buchungen bleiben, wie sie sind –
inklusive ihrer Kategorien, die damit sofort wieder in der Auswertung
zählen."""
db.delete(get_owned(db, TransferLink, link_id, user))
db.commit()
@router.post("/ignorieren", status_code=status.HTTP_201_CREATED)
def ignorieren(
payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> dict:
"""Vorschlag ablehnen, damit er bei jedem weiteren Suchlauf wegbleibt."""
a, b = _paar(db, user, payload)
klein, gross = sorted((a.id, b.id))
vorhanden = (
db.query(LinkIgnored)
.filter(LinkIgnored.owner_id == user.id,
LinkIgnored.a_txn_id == klein, LinkIgnored.b_txn_id == gross)
.first()
)
if vorhanden is None:
db.add(LinkIgnored(owner_id=user.id, a_txn_id=klein, b_txn_id=gross))
db.commit()
return {"ok": True}
+17
View File
@@ -49,6 +49,23 @@ def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db)
user = db.get(User, user_id) user = db.get(User, user_id)
if user is None: if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
if payload.username is not None:
neu = payload.username.strip()
if not neu:
raise HTTPException(status.HTTP_400_BAD_REQUEST,
"Benutzername darf nicht leer sein")
# Der Name ist ein Etikett, kein Schluessel – umbenennen zieht nichts
# mit. Eindeutig bleiben muss er trotzdem: Er ist das, was beim
# Passwort-Login eingetippt wird.
belegt = (
db.query(User.id)
.filter(User.username == neu, User.id != user.id)
.first()
)
if belegt is not None:
raise HTTPException(status.HTTP_409_CONFLICT,
"Benutzername existiert bereits")
user.username = neu
if payload.password is not None: if payload.password is not None:
policy_service.assert_ok(db, payload.password) policy_service.assert_ok(db, payload.password)
user.password_hash = hash_password(payload.password) user.password_hash = hash_password(payload.password)
+438 -4
View File
@@ -1,9 +1,12 @@
"""Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen).""" """Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen)."""
from datetime import date as date_t, datetime, time as time_t from datetime import date as date_t, datetime, time as time_t
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from . import waehrungen
from .models import ( from .models import (
AccountType, AccountType,
AmountMode, AmountMode,
@@ -12,16 +15,20 @@ from .models import (
IdentifierKind, IdentifierKind,
ImportStatus, ImportStatus,
Role, Role,
TokenScope,
TxnType, TxnType,
) )
ALLOWED_CURRENCIES = {"EUR", "CHF"} ALLOWED_CURRENCIES = waehrungen.ERLAUBT
def _check_currency(v: str) -> str: def _check_currency(v: str) -> str:
v = v.upper() v = v.upper()
if v not in ALLOWED_CURRENCIES: if v not in ALLOWED_CURRENCIES:
raise ValueError("Währung muss EUR oder CHF sein") raise ValueError(
f"'{v}' ist keine bekannte Währung. Möglich sind die Währungen mit "
f"EZB-Referenzkurs – ohne Kurs liesse sich der Betrag nie umrechnen."
)
return v return v
@@ -51,6 +58,11 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
# Umbenennen ist erlaubt: Der Benutzername ist ein ETIKETT, kein Schluessel.
# Alles Fachliche haengt an der Nummer des Nutzers – aendert sich der Name
# beim Anmeldedienst, muss Certain folgen koennen, ohne dass Daten
# umziehen.
username: str | None = Field(default=None, min_length=1, max_length=64)
password: str | None = Field(default=None, min_length=1, max_length=1024) password: str | None = Field(default=None, min_length=1, max_length=1024)
role: Role | None = None role: Role | None = None
display_name: str | None = Field(default=None, max_length=120) display_name: str | None = Field(default=None, max_length=120)
@@ -151,6 +163,12 @@ class AccountCreate(BaseModel):
color: str = "#3f51b5" color: str = "#3f51b5"
opening_balance_cents: int = 0 opening_balance_cents: int = 0
opening_date: date_t | None = None opening_date: date_t | None = None
# Was die Karte ausgeben darf – kein Guthaben. Nur bei Kreditkarten sinnvoll.
credit_limit_cents: int | None = Field(default=None, ge=0)
# Gesperrtes Geld (z. B. Saeule 3a): zaehlt nicht als verfuegbar.
is_locked: bool = False
# Ueberweisungen hierher zaehlen als Sparen gegen diese Ausgabe-Kategorie.
savings_category_id: int | None = None
identifiers: list[AccountIdentifierIn] = Field(default_factory=list) identifiers: list[AccountIdentifierIn] = Field(default_factory=list)
@field_validator("currency") @field_validator("currency")
@@ -159,6 +177,12 @@ class AccountCreate(BaseModel):
return _check_currency(v) return _check_currency(v)
class AccountOrder(BaseModel):
"""Konto-IDs in der gewünschten Reihenfolge."""
ids: list[int]
class AccountUpdate(BaseModel): class AccountUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120) name: str | None = Field(default=None, min_length=1, max_length=120)
type: AccountType | None = None type: AccountType | None = None
@@ -167,8 +191,11 @@ class AccountUpdate(BaseModel):
color: str | None = None color: str | None = None
opening_balance_cents: int | None = None opening_balance_cents: int | None = None
opening_date: date_t | None = None opening_date: date_t | None = None
credit_limit_cents: int | None = Field(default=None, ge=0)
is_locked: bool | None = None
is_archived: bool | None = None is_archived: bool | None = None
position: int | None = None position: int | None = None
savings_category_id: int | None = None
# Wenn gesetzt, ersetzt die komplette Identifikator-Liste. # Wenn gesetzt, ersetzt die komplette Identifikator-Liste.
identifiers: list[AccountIdentifierIn] | None = None identifiers: list[AccountIdentifierIn] | None = None
@@ -187,11 +214,159 @@ class AccountOut(BaseModel):
color: str color: str
opening_balance_cents: int opening_balance_cents: int
opening_date: date_t | None opening_date: date_t | None
credit_limit_cents: int | None = None
is_locked: bool = False
is_archived: bool is_archived: bool
position: int position: int
savings_category_id: int | None = None
identifiers: list[AccountIdentifierOut] identifiers: list[AccountIdentifierOut]
balance_cents: int balance_cents: int
balance_base_cents: int balance_base_cents: int
# Saldo, wie ihn die Bank fuehrt – nur bei angebundenen Konten gesetzt. Er
# ersetzt die eigene Rechnung nicht, er prueft sie.
bank_balance_cents: int | None = None
bank_balance_at: datetime | None = None
# Nur ein GEBUCHTER Saldo ist mit Certains Rechnung vergleichbar. Ein
# verfuegbarer enthaelt Vorgemerktes, das hier bewusst nicht gebucht wird.
bank_balance_vergleichbar: bool = False
# --- Abos & Verträge --------------------------------------------------------
# Was als Intervall erlaubt ist. Offen genug für einen zweimonatlichen Vertrag,
# eng genug, dass niemand "0" oder "37" einträgt.
INTERVALL_MONATE = (1, 2, 3, 6, 12)
class SubscriptionIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
icon: str = "refresh-ccw"
color: str = "#6b7480"
amount_cents: int = Field(ge=0)
currency: str = "EUR"
interval_months: int = 1
account_id: int | None = None
category_id: int | None = None
next_due: date_t | None = None
payee_pattern: str | None = Field(default=None, max_length=255)
savings_account_id: int | None = None
is_active: bool = True
cancelled_at: date_t | None = None
notes: str | None = None
@field_validator("currency")
@classmethod
def _cur(cls, v: str) -> str:
return _check_currency(v)
@field_validator("interval_months")
@classmethod
def _iv(cls, v: int) -> int:
if v not in INTERVALL_MONATE:
raise ValueError(f"Intervall muss eines von {INTERVALL_MONATE} Monaten sein")
return v
class SubscriptionUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
icon: str | None = None
color: str | None = None
amount_cents: int | None = Field(default=None, ge=0)
currency: str | None = None
interval_months: int | None = None
account_id: int | None = None
category_id: int | None = None
next_due: date_t | None = None
payee_pattern: str | None = Field(default=None, max_length=255)
savings_account_id: int | None = None
is_active: bool | None = None
cancelled_at: date_t | None = None
notes: str | None = None
@field_validator("currency")
@classmethod
def _cur(cls, v: str | None) -> str | None:
return _check_currency(v) if v is not None else None
@field_validator("interval_months")
@classmethod
def _iv(cls, v: int | None) -> int | None:
if v is not None and v not in INTERVALL_MONATE:
raise ValueError(f"Intervall muss eines von {INTERVALL_MONATE} Monaten sein")
return v
class SubscriptionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
icon: str
color: str
amount_cents: int
currency: str
interval_months: int
account_id: int | None
category_id: int | None
next_due: date_t | None
payee_pattern: str | None
savings_account_id: int | None
is_active: bool
cancelled_at: date_t | None
notes: str | None
position: int
# Gerechnet, nicht gespeichert – in der Währung des Vertrags.
monatlich_cents: int
# Und derselbe Wert in der Hauptwährung, damit sich Zeilen vergleichen und
# summieren lassen. None, wenn für die Währung kein Kurs vorliegt.
monatlich_base_cents: int | None = None
# Wie viele Tage bis zur nächsten Abbuchung (negativ = überfällig).
tage_bis_faellig: int | None = None
# Ob ein Markenlogo hinterlegt ist. Nur die Angabe, nicht das Bild – das
# holt die Anzeige einzeln, sonst hinge die Liste voller Base64.
has_logo: bool = False
# Ändert sich beim Austauschen und überholt so den Zwischenspeicher des
# Browsers – ohne das zeigte er nach dem Wechsel weiter das alte Bild.
logo_version: int | None = None
class SubscriptionSummary(BaseModel):
"""Was am Kopf der Seite steht – die Zahl, wegen der man sie öffnet."""
currency: str
monatlich_cents: int
jaehrlich_cents: int
anzahl_aktiv: int
# Verträge, deren Währung sich mangels Kurs nicht umrechnen liess. Lieber
# nennen als stillschweigend zu klein summieren.
ohne_kurs: list[str] = Field(default_factory=list)
class SubscriptionListOut(BaseModel):
subscriptions: list[SubscriptionOut]
summary: SubscriptionSummary
# --- Tags ------------------------------------------------------------------
class TagOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
color: str
class TagCreate(BaseModel):
name: str = Field(min_length=1, max_length=60)
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
color: str = "#3f9c72"
class TagUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=60)
color: str | None = None
# --- Kategorien ------------------------------------------------------------ # --- Kategorien ------------------------------------------------------------
@@ -209,6 +384,11 @@ class CategoryOut(BaseModel):
position: int position: int
is_builtin: bool is_builtin: bool
is_archived: bool is_archived: bool
excluded_from_stats: bool = False
spending_is_good: bool = False
# Anzahl hinterlegter Schlagwörter. Nur in der Liste gefüllt, damit die
# Übersicht ohne einen Abruf je Kategorie auskommt.
keyword_count: int = 0
class CategoryGroupOut(BaseModel): class CategoryGroupOut(BaseModel):
@@ -226,7 +406,7 @@ class CategoryGroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
kind: CategoryKind = CategoryKind.expense kind: CategoryKind = CategoryKind.expense
icon: str = "list" icon: str = "list"
color: str = "#6b7480" color: str = "#3f9c72"
class CategoryGroupUpdate(BaseModel): class CategoryGroupUpdate(BaseModel):
@@ -240,7 +420,9 @@ class CategoryCreate(BaseModel):
group_id: int group_id: int
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
icon: str = "more-horizontal" icon: str = "more-horizontal"
color: str = "#6b7480" color: str = "#3f9c72"
excluded_from_stats: bool = False
spending_is_good: bool = False
class CategoryUpdate(BaseModel): class CategoryUpdate(BaseModel):
@@ -250,6 +432,8 @@ class CategoryUpdate(BaseModel):
color: str | None = None color: str | None = None
position: int | None = None position: int | None = None
is_archived: bool | None = None is_archived: bool | None = None
excluded_from_stats: bool | None = None
spending_is_good: bool | None = None
# --- Transaktionen --------------------------------------------------------- # --- Transaktionen ---------------------------------------------------------
@@ -267,9 +451,14 @@ class TransactionCreate(BaseModel):
category_id: int | None = None category_id: int | None = None
budget_treatment: BudgetTreatment | None = None budget_treatment: BudgetTreatment | None = None
budget_category_id: int | None = None budget_category_id: int | None = None
# Budget-Monat (YYYY-MM). Leer heisst: aus dem Datum ableiten.
budget_period: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}$")
payee: str | None = Field(default=None, max_length=255) payee: str | None = Field(default=None, max_length=255)
description: str | None = Field(default=None, max_length=255) description: str | None = Field(default=None, max_length=255)
notes: str | None = None notes: str | None = None
# Beim Anlegen gleich mitgeben, sonst muesste man die Buchung direkt
# danach noch einmal oeffnen, nur um sie zu etikettieren.
tag_ids: list[int] = []
class TransactionUpdate(BaseModel): class TransactionUpdate(BaseModel):
@@ -284,9 +473,15 @@ class TransactionUpdate(BaseModel):
category_id: int | None = None category_id: int | None = None
budget_treatment: BudgetTreatment | None = None budget_treatment: BudgetTreatment | None = None
budget_category_id: int | None = None budget_category_id: int | None = None
# Budget-Monat (YYYY-MM). Leer heisst: aus dem Datum ableiten.
budget_period: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}$")
payee: str | None = Field(default=None, max_length=255) payee: str | None = Field(default=None, max_length=255)
description: str | None = Field(default=None, max_length=255) description: str | None = Field(default=None, max_length=255)
notes: str | None = None notes: str | None = None
# Die Tags dieser Buchung, vollstaendig. Mitgeschickt heisst ERSETZEN –
# eine leere Liste nimmt alle weg. Weggelassen laesst sie unberuehrt;
# darum haengt hier ``exclude_unset`` im Router dran.
tag_ids: list[int] | None = None
class TransactionOut(BaseModel): class TransactionOut(BaseModel):
@@ -305,9 +500,27 @@ class TransactionOut(BaseModel):
category_id: int | None category_id: int | None
budget_treatment: BudgetTreatment | None budget_treatment: BudgetTreatment | None
budget_category_id: int | None budget_category_id: int | None
# Der Vertrag, zu dem diese Buchung gehoert – ein Etikett, von der
# Erkennung gesetzt. Steht in der AUSGABE, nicht in der Eingabe: Beim
# Anlegen gibt man es nicht mit.
subscription_id: int | None = None
# Der Budget-Monat, in dem die Buchung zaehlt – immer gesetzt. Ob er von
# Hand gewaehlt wurde oder aus dem Datum stammt, sagt das zweite Feld.
budget_period: str
budget_period_manuell: bool = False
# Woher die Buchung stammt, fuer die Arbeitsliste: Ist sie gesetzt,
# kam die Buchung aus einem Datei-Import.
import_batch_id: int | None = None
# Woher die Buchung stammt: bank, mail, shortcut, transfer, csv, manuell.
herkunft: str = "manuell"
payee: str | None payee: str | None
description: str | None description: str | None
notes: str | None notes: str | None
# Die Tags dieser Buchung. Anders als die Uebertrags-Verknuepfung stehen
# sie IM Schema und nicht in einem eigenen Endpunkt: Jede Zeile der
# Buchungsliste zeigt und filtert nach ihnen, ein zweiter Abruf wuerde im
# Browser doch nur wieder zusammengefuegt.
tag_ids: list[int] = []
class RateStatus(BaseModel): class RateStatus(BaseModel):
@@ -368,6 +581,8 @@ class ImportTemplateCreate(BaseModel):
skip_rows: int = 0 skip_rows: int = 0
amount_mode: AmountMode = AmountMode.signed amount_mode: AmountMode = AmountMode.signed
column_map: dict[str, int | None] = Field(default_factory=dict) column_map: dict[str, int | None] = Field(default_factory=dict)
filter_column: int | None = None
filter_value: str | None = None
class ImportTemplateUpdate(BaseModel): class ImportTemplateUpdate(BaseModel):
@@ -380,6 +595,8 @@ class ImportTemplateUpdate(BaseModel):
skip_rows: int | None = None skip_rows: int | None = None
amount_mode: AmountMode | None = None amount_mode: AmountMode | None = None
column_map: dict[str, int | None] | None = None column_map: dict[str, int | None] | None = None
filter_column: int | None = None
filter_value: str | None = None
class ImportTemplateOut(BaseModel): class ImportTemplateOut(BaseModel):
@@ -393,6 +610,8 @@ class ImportTemplateOut(BaseModel):
skip_rows: int skip_rows: int
amount_mode: AmountMode amount_mode: AmountMode
column_map: dict[str, int | None] column_map: dict[str, int | None]
filter_column: int | None = None
filter_value: str | None = None
class ImportBatchOut(BaseModel): class ImportBatchOut(BaseModel):
@@ -401,16 +620,22 @@ class ImportBatchOut(BaseModel):
id: int id: int
account_id: int account_id: int
template_id: int | None template_id: int | None
preset: str | None
filename: str filename: str
row_count: int row_count: int
imported_count: int imported_count: int
status: ImportStatus status: ImportStatus
created_at: datetime created_at: datetime
# Wie viele Uebertraege beim Buchen erkannt wurden. Nicht gespeichert –
# nur die Rueckmeldung eines Vorgangs. Sonst erfaehrt niemand, dass eine
# Kartenrechnung gerade still mit ihrer Gegenbuchung verbunden wurde.
uebertraege_verknuepft: int = 0
class ImportCommitIn(BaseModel): class ImportCommitIn(BaseModel):
account_id: int account_id: int
template_id: int | None = None template_id: int | None = None
preset: str | None = None
filename: str = "" filename: str = ""
rows: list[dict] = Field(default_factory=list) rows: list[dict] = Field(default_factory=list)
@@ -438,12 +663,14 @@ class KeywordOut(BaseModel):
class TransferKeywordCreate(BaseModel): class TransferKeywordCreate(BaseModel):
word: str = Field(min_length=1, max_length=255) word: str = Field(min_length=1, max_length=255)
amount_cents: int | None = Field(default=None, gt=0) amount_cents: int | None = Field(default=None, gt=0)
target_account_id: int | None = None
class TransferKeywordOut(BaseModel): class TransferKeywordOut(BaseModel):
id: int id: int
word: str word: str
amount_cents: int | None = None amount_cents: int | None = None
target_account_id: int | None = None
class FlaggedKeywordOut(BaseModel): class FlaggedKeywordOut(BaseModel):
@@ -453,3 +680,210 @@ class FlaggedKeywordOut(BaseModel):
word: str word: str
applied_count: int applied_count: int
overridden_count: int overridden_count: int
# --- Gerätemarken und Eingang ------------------------------------------------
class ApiTokenCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
default_account_id: int | None = None
# Voreinstellung bleibt die enge Marke: Wer nichts angibt, bekommt keinen
# Vollzugang.
scope: TokenScope = TokenScope.quick_add
class ApiTokenOut(BaseModel):
id: int
name: str
scope: TokenScope
default_account_id: int | None
last_used_at: datetime | None
created_at: datetime | None
model_config = ConfigDict(from_attributes=True)
class ApiTokenCreated(ApiTokenOut):
"""Antwort beim Anlegen – der Klartext ist hier das einzige Mal zu sehen."""
token: str
class QuickBookingIn(BaseModel):
"""Was ein Kurzbefehl schickt. Bewusst genügsam: Betrag reicht."""
amount: Decimal
payee: str | None = Field(default=None, max_length=255)
date: date_t | None = None
account_id: int | None = None
note: str | None = None
# Kennung des Kurzbefehls, damit ein doppelt ausgelöster Aufruf nicht
# zweimal bucht (schlechtes Netz, versehentlicher Doppeltipp).
external_id: str | None = Field(default=None, max_length=255)
kind: Literal["expense", "income"] = "expense"
class InboxItemOut(BaseModel):
id: int
source: str
status: str
account_id: int | None
date: date_t
amount_cents: int
currency: str
payee: str | None
note: str | None
category_id: int | None
transaction_id: int | None
# Das jeweils ANDERE Konto, wenn die Buchung ein Kontoübertrag ist. Ohne
# das kann die Oberfläche einen Übertrag nicht von einer Ausgabe
# unterscheiden und fragt weiter nach einer Kategorie.
transfer_account_id: int | None = None
# Was die Bank ueber die Umrechnung mitteilt – ihr Kurs schlaegt die EZB.
fx_rate: float | None = None
counter_amount_cents: int | None = None
counter_currency: str | None = None
created_at: datetime | None
model_config = ConfigDict(from_attributes=True)
class QuickBookingOut(BaseModel):
"""Kurze Rückmeldung – der Kurzbefehl zeigt sie als Mitteilung an."""
ok: bool = True
message: str
item: InboxItemOut
# --- Postfächer für den Mail-Abruf -------------------------------------------
class MailSourceIn(BaseModel):
name: str = Field(default="", max_length=80)
host: str = Field(min_length=1, max_length=255)
port: int = 993
security: Literal["ssl", "starttls", "none"] = "ssl"
username: str = Field(min_length=1, max_length=255)
# Beim Ändern weglassen, um das gespeicherte Passwort zu behalten.
password: str | None = None
folder: str = "INBOX"
done_folder: str | None = None
mark_seen: bool = True
is_active: bool = True
poll_minutes: int = Field(default=15, ge=5, le=1440)
class MailSourceOut(BaseModel):
id: int
name: str
host: str
port: int
security: str
username: str
folder: str
done_folder: str | None
mark_seen: bool
is_active: bool
poll_minutes: int
last_poll_at: datetime | None
last_error: str | None
# Das Passwort verlässt den Server nie – nur die Tatsache, dass eines da ist.
password_set: bool = True
model_config = ConfigDict(from_attributes=True)
class MailSenderIn(BaseModel):
name: str = Field(default="", max_length=80)
from_contains: str | None = Field(default=None, max_length=255)
subject_contains: str | None = Field(default=None, max_length=255)
currency: str | None = Field(default=None, max_length=3)
account_id: int | None = None
class MailSenderOut(MailSenderIn):
id: int
source_id: int
model_config = ConfigDict(from_attributes=True)
# --- Bankanbindung (PSD2) ----------------------------------------------------
class BankAppIn(BaseModel):
application_id: str = Field(min_length=1, max_length=64)
# Inhalt der .pem-Datei aus dem Control Panel.
private_key: str = Field(min_length=1)
# Öffentliche Adresse dieser Installation; die Bank leitet dorthin zurück.
base_url: str = Field(min_length=1, max_length=255)
@field_validator("base_url")
@classmethod
def nur_https(cls, v: str) -> str:
"""Ohne HTTPS lehnt die Bank die Rückleitung ab.
Bewusst eingetragen statt erkannt: Hinter einem Proxy sieht der Dienst
selbst „http", obwohl der Browser HTTPS spricht – automatisches Raten
würde hier zuverlässig das Falsche liefern.
"""
v = v.strip().rstrip("/")
if not v.lower().startswith("https://"):
raise ValueError(
"Die Adresse muss mit https:// beginnen. Banken leiten nicht "
"auf eine unverschlüsselte Adresse zurück."
)
return v
class BankAppOut(BaseModel):
id: int
application_id: str
base_url: str
created_at: datetime | None
# Der Schlüssel verlässt den Server nie – nur die Tatsache, dass einer da ist.
key_set: bool = True
model_config = ConfigDict(from_attributes=True)
class BankConnectionOut(BaseModel):
id: int
aspsp_name: str
aspsp_country: str
valid_until: datetime | None
last_sync_at: datetime | None
# Takt des automatischen Abrufs in Minuten, 0 = aus.
poll_minutes: int = 0
last_error: str | None
expires_soon: bool = False
model_config = ConfigDict(from_attributes=True)
class BankAccountLinkOut(BaseModel):
id: int
connection_id: int
remote_uid: str
iban: str | None
remote_name: str | None
product: str | None = None
other_id: str | None = None
bank_felder: dict = {}
currency: str
balance_cents: int | None = None
balance_type: str | None = None
balance_vergleichbar: bool = False
balance_at: datetime | None = None
last_sync_at: datetime | None = None
account_id: int | None
model_config = ConfigDict(from_attributes=True)
class BankConnectStart(BaseModel):
aspsp_name: str = Field(min_length=1, max_length=120)
country: str = Field(min_length=2, max_length=2)
psu_type: Literal["personal", "business"] = "personal"
+316
View File
@@ -0,0 +1,316 @@
"""Welche Buchung gehört zu welchem Vertrag.
Eine Schicht OBENDRAUF, wie die Übertrags-Verknüpfung: An der Buchung ändert
sich nichts. Hier steht nur, dass sie die Abbuchung eines Vertrags ist. Fällt
die Zuordnung falsch aus, zieht man ein Etikett ab – keine Buchung ist verbogen.
Ein Vertrag weiß vier Dinge über seine Abbuchung: Konto, Betrag, ungefähres
Datum und einen Text. Daraus ergeben sich drei Stufen:
**Sicher** Betrag stimmt auf den Cent, und der Text passt. Wird
zugeordnet, ohne zu fragen.
**Vorschlag** Betrag stimmt, Text nicht – oder der Text passt, aber der
Betrag ist ein anderer. Das zweite ist der interessante Fall:
So sieht eine Preiserhöhung aus.
**Nichts** Alles andere. Lieber nichts sagen als daneben raten.
Was hier NICHT passiert: den Preis am Vertrag stillschweigend nachziehen. Eine
Preiserhöhung ist eine Nachricht an den Nutzer, kein Wartungsvorgang.
"""
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from sqlalchemy.orm import Session
from ..models import Account, Subscription, Transaction, TxnType, User
from . import currency
from .csv_import import normalize_text
log = logging.getLogger(__name__)
# Wie weit das Buchungsdatum von der erwarteten Fälligkeit abweichen darf und
# trotzdem als dieselbe Abbuchung gilt. Grosszügig: Banken buchen über
# Wochenenden und Feiertage verschoben, und wer den Vertrag von Hand angelegt
# hat, kennt den genauen Tag oft nicht.
FENSTER_TAGE = 12
# Ohne eingetragene Fälligkeit muss ein weiteres Fenster herhalten – dann
# entscheidet praktisch allein der Betrag samt Text.
FENSTER_OHNE_FAELLIGKEIT = 45
# Wie weit der Betrag abweichen darf und noch als PREISÄNDERUNG desselben
# Vertrags gilt (statt als fremde Zahlung). Halb so viel bis doppelt so viel:
# Ein Abo, das sich verdreifacht, ist kein Abo mehr, sondern etwas anderes.
PREIS_UNTEN = 0.5
PREIS_OBEN = 2.0
@dataclass
class Treffer:
"""Eine Buchung und der Vertrag, zu dem sie gehören könnte."""
txn: Transaction
vertrag: Subscription
sicher: bool
# Der Betrag des Vertrags in der Währung der Buchung – zum Vergleichen.
erwartet_cents: int | None
@property
def preis_geaendert(self) -> bool:
return self.erwartet_cents is not None and self.txn.amount_cents != self.erwartet_cents
def _texte(t: Transaction) -> str:
return normalize_text(f"{t.payee or ''} {t.description or ''}")
def _passt_text(vertrag: Subscription, text: str) -> bool:
"""Steht der Erkennungstext des Vertrags in der Buchung?
Wortweise und nicht als Ganzes: Auf dem Auszug steht „NETFLIX.COM 1234"
oder „Salt Mobile SA Rechnung" – der eingetragene Name kommt dort selten
am Stück vor. Kurze Wörter bleiben draussen; „SA" oder „AG" trifft sonst
die halbe Kontoauszug.
"""
# Beides pruefen, nicht entweder/oder: Der gelernte Text (payee_pattern)
# kommt beim Zuordnen von Hand dazu und soll den Namen nicht verdraengen.
for roh in (vertrag.payee_pattern, vertrag.name):
muster = normalize_text(roh)
if not muster:
continue
if muster in text:
return True
woerter = [w for w in muster.split() if len(w) >= 4]
if woerter and any(w in text for w in woerter):
return True
return False
def _erwartet_in(db: Session, vertrag: Subscription, konto: Account,
am: date) -> int | None:
"""Was der Vertrag in der Währung DES KONTOS kosten sollte.
Ein Vertrag in Euro, bezahlt von einem Franken-Konto, kommt jeden Monat mit
einem anderen Frankenbetrag an. Der exakte Vergleich wäre dort immer
falsch – also wird umgerechnet, und der Betrag darf danach etwas
abweichen (siehe ``_betrag_passt``).
"""
if vertrag.currency == konto.currency:
return vertrag.amount_cents
return currency.convert(db, vertrag.amount_cents, vertrag.currency,
konto.currency, am)
def _betrag_passt(ist: int, erwartet: int, gleiche_waehrung: bool) -> bool:
"""Ist das derselbe Betrag?
Bei gleicher Währung auf den Cent – ein Abo kostet, was es kostet. Bei
umgerechneter Währung mit Spielraum: Der Kurs von heute ist nicht der vom
Buchungstag, und die Bank rechnet mit ihrem eigenen.
"""
if gleiche_waehrung:
return ist == erwartet
if erwartet <= 0:
return False
return abs(ist - erwartet) <= max(50, round(erwartet * 0.05))
def _im_fenster(tag: date, faellig: date | None, intervall: int) -> bool:
"""Liegt der Tag nahe genug an einer erwarteten Fälligkeit?
Gerechnet wird über MEHRERE Intervalle rückwärts und vorwärts: Wer seine
Verträge nachträgt, hat Buchungen aus Monaten, die längst vorbei sind, und
``next_due`` steht in der Zukunft.
"""
if faellig is None:
return True
for n in range(-24, 25):
erwartet = faellig + relativedelta(months=intervall * n)
if abs((tag - erwartet).days) <= FENSTER_TAGE:
return True
if erwartet > tag + timedelta(days=FENSTER_TAGE * 2) and n > 0:
break
return False
def _kandidaten(db: Session, user: User) -> list[Transaction]:
"""Buchungen, die noch keinem Vertrag zugeordnet sind.
Überträge bleiben draussen: Ein Abo wird bezahlt, nicht zwischen eigenen
Konten verschoben. Verknüpfte Buchungen ebenfalls – die sind schon als
etwas anderes erkannt.
"""
from .uebertraege import verknuepfte_ids
verknuepft = verknuepfte_ids(db, user)
return [
t for t in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.expense,
Transaction.subscription_id.is_(None),
)
if t.id not in verknuepft
]
def _pruefe(db: Session, t: Transaction, vertrag: Subscription,
konto: Account) -> Treffer | None:
"""Passt diese eine Buchung zu diesem einen Vertrag?"""
if vertrag.account_id is not None and vertrag.account_id != t.account_id:
return None
erwartet = _erwartet_in(db, vertrag, konto, t.date)
if erwartet is None:
return None
gleiche = vertrag.currency == konto.currency
betrag_ok = _betrag_passt(t.amount_cents, erwartet, gleiche)
text_ok = _passt_text(vertrag, _texte(t))
if betrag_ok and text_ok and _im_fenster(t.date, vertrag.next_due,
vertrag.interval_months):
return Treffer(t, vertrag, True, erwartet)
# Betrag stimmt, Text nicht: Auf dem Auszug steht oft der Abwickler
# ("STRIPE") und nicht die Marke. Fragen statt raten.
if betrag_ok and _im_fenster(t.date, vertrag.next_due, vertrag.interval_months):
return Treffer(t, vertrag, False, erwartet)
# Text stimmt, Betrag nicht – SO SIEHT EINE PREISERHOEHUNG AUS. Der
# wertvollste Fall, und einer, den man nie automatisch verbuchen darf.
if text_ok and erwartet > 0:
verhaeltnis = t.amount_cents / erwartet
if PREIS_UNTEN <= verhaeltnis <= PREIS_OBEN:
fenster = (vertrag.next_due is not None
and _im_fenster(t.date, vertrag.next_due, vertrag.interval_months))
ohne = vertrag.next_due is None
if fenster or ohne:
return Treffer(t, vertrag, False, erwartet)
return None
def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
"""Buchungen ihren Verträgen zuordnen.
Eindeutig Sicheres wird zugeordnet, der Rest kommt als Vorschlag zurück.
Mit ``nur_vorschlaege`` wird NICHTS zugeordnet – das braucht jede Ansicht,
die bloss fragen will, ohne beim Hinsehen schon etwas zu verändern.
"""
vertraege = (
db.query(Subscription)
.filter(Subscription.owner_id == user.id, Subscription.is_active.is_(True))
.all()
)
if not vertraege:
return {"zugeordnet": 0, "vorschlaege": [], "preisaenderungen": []}
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
zugeordnet = 0
vorschlaege: list[Treffer] = []
for t in _kandidaten(db, user):
konto = konten.get(t.account_id)
if konto is None:
continue
treffer = [x for x in (_pruefe(db, t, v, konto) for v in vertraege) if x]
if not treffer:
continue
sichere = [x for x in treffer if x.sicher]
# Nur wenn GENAU EINER sicher passt. Zwei Abos zum selben Preis am
# selben Tag gibt es, und dann soll der Nutzer entscheiden.
if len(sichere) == 1 and not nur_vorschlaege:
t.subscription_id = sichere[0].vertrag.id
_ruecke_faelligkeit(sichere[0].vertrag, t.date)
zugeordnet += 1
continue
if len(sichere) == 1:
vorschlaege.append(sichere[0])
continue
# Mehrdeutig oder unsicher: der beste Vorschlag je Buchung genuegt.
vorschlaege.append(sichere[0] if sichere else treffer[0])
if zugeordnet:
db.commit()
return {
"zugeordnet": zugeordnet,
"vorschlaege": [_kurz(db, x) for x in vorschlaege],
"preisaenderungen": [_kurz(db, x) for x in vorschlaege if x.preis_geaendert],
}
def _ruecke_faelligkeit(vertrag: Subscription, gebucht_am: date) -> None:
"""Die nächste Fälligkeit hinter die gerade erkannte Abbuchung schieben.
Nur vorwärts: Wer alte Buchungen nachträgt, soll die Fälligkeit nicht in
die Vergangenheit ziehen.
"""
naechste = gebucht_am + relativedelta(months=vertrag.interval_months)
if vertrag.next_due is None or naechste > vertrag.next_due:
vertrag.next_due = naechste
def _kurz(db: Session, x: Treffer) -> dict:
konto = db.get(Account, x.txn.account_id)
return {
"txn_id": x.txn.id,
"date": x.txn.date.isoformat(),
"amount_cents": x.txn.amount_cents,
"currency": konto.currency if konto else "",
"payee": x.txn.payee,
"description": x.txn.description,
"account_name": konto.name if konto else "",
"subscription_id": x.vertrag.id,
"subscription_name": x.vertrag.name,
"erwartet_cents": x.erwartet_cents,
"preis_geaendert": x.preis_geaendert,
}
def verlauf(db: Session, user: User, vertrag: Subscription) -> list[dict]:
"""Die erkannten Abbuchungen eines Vertrags, neueste zuerst.
Damit beantwortet sich „was habe ich dafür insgesamt bezahlt" – und man
sieht, wann der Preis gestiegen ist.
"""
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
zeilen = []
for t in (
db.query(Transaction)
.filter(Transaction.owner_id == user.id,
Transaction.subscription_id == vertrag.id)
.order_by(Transaction.date.desc(), Transaction.id.desc())
):
konto = konten.get(t.account_id)
zeilen.append({
"txn_id": t.id,
"date": t.date.isoformat(),
"amount_cents": t.amount_cents,
"currency": konto.currency if konto else "",
"payee": t.payee,
"account_name": konto.name if konto else "",
})
return zeilen
def summe_je_vertrag(db: Session, user: User) -> dict[int, dict]:
"""Je Vertrag: wie viele Abbuchungen erkannt sind und was sie zusammen
gekostet haben. Fuer die Liste, ohne je Zeile einzeln nachzufragen."""
ergebnis: dict[int, dict] = defaultdict(lambda: {"anzahl": 0, "summe_cents": 0,
"letzte": None})
for t in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.subscription_id.isnot(None),
):
eintrag = ergebnis[t.subscription_id]
eintrag["anzahl"] += 1
eintrag["summe_cents"] += t.amount_cents
iso = t.date.isoformat()
if eintrag["letzte"] is None or iso > eintrag["letzte"]:
eintrag["letzte"] = iso
return dict(ergebnis)
+75
View File
@@ -0,0 +1,75 @@
"""Was die Verträge je Kategorie im Monat kosten.
Die eine Stelle, an der aus Verträgen eine Budget-Zahl wird. Budget-Seite und
Dashboard fragen beide hier – sonst könnten sie unterschiedliche Antworten
geben, und man müsste raten, welche stimmt.
Gerechnet wird in der Hauptwährung: Ein Vertrag in Euro und einer in Franken
lassen sich sonst nicht addieren. Was sich mangels Kurs nicht umrechnen lässt,
wird GEMELDET und nicht stillschweigend als Null verbucht – eine zu kleine
Summe im Budget ist schlimmer als gar keine.
"""
from __future__ import annotations
from collections import defaultdict
from datetime import date
from sqlalchemy.orm import Session
from ..models import Subscription, User
from . import currency
def _monatlich_in(db: Session, s: Subscription, base: str, heute: date) -> int | None:
"""Monatswert eines Vertrags in der Hauptwährung, oder None ohne Kurs."""
monatlich = s.monatlich_cents
if s.currency == base:
return monatlich
return currency.convert(db, monatlich, s.currency, base, heute)
def uebersicht(db: Session, user: User, base: str) -> dict:
"""Alles, was die Budget-Seite über die Verträge wissen muss.
``je_kategorie`` Monatswert je Kategorie, in Hauptwährung
``anzahl`` wie viele Verträge je Kategorie – „aus 2 Verträgen"
``ohne_kategorie`` Summe und Anzahl der Verträge, die keiner Kategorie
zugeordnet sind. Ohne diese Angabe fehlt ihr Geld im
Budget, ohne dass irgendwo steht, dass es fehlt.
``ohne_kurs`` Währungen, die sich nicht umrechnen liessen.
"""
heute = date.today()
je_kategorie: dict[int, int] = defaultdict(int)
anzahl: dict[int, int] = defaultdict(int)
ohne_kategorie_cents = 0
ohne_kategorie_anzahl = 0
ohne_kurs: set[str] = set()
for s in db.query(Subscription).filter(
Subscription.owner_id == user.id,
Subscription.is_active.is_(True),
):
cents = _monatlich_in(db, s, base, heute)
if cents is None:
ohne_kurs.add(s.currency)
continue
if s.category_id is None:
ohne_kategorie_cents += cents
ohne_kategorie_anzahl += 1
continue
je_kategorie[s.category_id] += cents
anzahl[s.category_id] += 1
return {
"je_kategorie": dict(je_kategorie),
"anzahl": dict(anzahl),
"ohne_kategorie_cents": ohne_kategorie_cents,
"ohne_kategorie_anzahl": ohne_kategorie_anzahl,
"ohne_kurs": sorted(ohne_kurs),
}
def monatlich_je_kategorie(db: Session, user: User, base: str) -> dict[int, int]:
"""Nur die Beträge – für die Soll-Berechnung."""
return uebersicht(db, user, base)["je_kategorie"]
+130 -11
View File
@@ -20,13 +20,14 @@ Zwei Reichweiten:
from __future__ import annotations from __future__ import annotations
import base64
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date as date_t, datetime, time as time_t from datetime import date as date_t, datetime, time as time_t
from decimal import Decimal from decimal import Decimal
from typing import Callable from typing import Callable
from sqlalchemy import Boolean, Date, DateTime, Numeric, Time from sqlalchemy import Boolean, Date, DateTime, LargeBinary, Numeric, Time
from sqlalchemy import Enum as SAEnum from sqlalchemy import Enum as SAEnum
from sqlalchemy import inspect as sa_inspect from sqlalchemy import inspect as sa_inspect
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -35,6 +36,7 @@ from ..branding import VERSION
from ..models import ( from ..models import (
Account, Account,
AccountIdentifier, AccountIdentifier,
ApiToken,
AppSetting, AppSetting,
BudgetPeriod, BudgetPeriod,
BudgetPeriodLine, BudgetPeriodLine,
@@ -46,9 +48,17 @@ from ..models import (
ExchangeRate, ExchangeRate,
ImportBatch, ImportBatch,
ImportTemplate, ImportTemplate,
InboxItem,
Subscription,
SubscriptionLogo,
UserIdentity,
LinkIgnored,
PlannedIncomeLine, PlannedIncomeLine,
RecoveryCode, RecoveryCode,
Tag,
Transaction, Transaction,
TransactionTag,
TransferLink,
User, User,
UserSetting, UserSetting,
) )
@@ -82,22 +92,40 @@ class TableSpec:
# Reihenfolge = Einfügereihenfolge. Verweise dürfen nur auf bereits # Reihenfolge = Einfügereihenfolge. Verweise dürfen nur auf bereits
# eingefügte Tabellen zeigen; gelöscht wird in umgekehrter Reihenfolge. # eingefügte Tabellen zeigen; gelöscht wird in umgekehrter Reihenfolge.
USER_TABLES: list[TableSpec] = [ USER_TABLES: list[TableSpec] = [
TableSpec("accounts", Account, "owner", label="Konten"), # Kategorien VOR den Konten: Ein Konto kann auf eine Spar-Kategorie zeigen,
TableSpec("account_identifiers", AccountIdentifier, "account", # und ein Verweis darf nur auf eine schon eingefuegte Tabelle gehen.
{"account_id": "accounts"}, label="Konto-Merkmale"), # Umgekehrt zeigt keine Kategorie je auf ein Konto.
TableSpec("category_groups", CategoryGroup, "owner", label="Kategoriegruppen"), TableSpec("category_groups", CategoryGroup, "owner", label="Kategoriegruppen"),
TableSpec("categories", Category, "owner", TableSpec("categories", Category, "owner",
{"group_id": "category_groups"}, label="Kategorien"), {"group_id": "category_groups"}, label="Kategorien"),
TableSpec("accounts", Account, "owner",
{"savings_category_id": "categories"}, label="Konten"),
TableSpec("account_identifiers", AccountIdentifier, "account",
{"account_id": "accounts"}, label="Konto-Merkmale"),
TableSpec("import_templates", ImportTemplate, "owner", label="Import-Vorlagen"), TableSpec("import_templates", ImportTemplate, "owner", label="Import-Vorlagen"),
TableSpec("import_batches", ImportBatch, "owner", TableSpec("import_batches", ImportBatch, "owner",
{"account_id": "accounts", "template_id": "import_templates"}, {"account_id": "accounts", "template_id": "import_templates"},
label="Import-Läufe"), label="Import-Läufe"),
# Abos & Vertraege VOR den Buchungen: Eine Buchung traegt das Etikett ihres
# Vertrags, und ein Verweis darf nur auf eine schon eingefuegte Tabelle
# zeigen. Umgekehrt verweist kein Vertrag auf eine Buchung.
TableSpec("subscriptions", Subscription, "owner", {
"account_id": "accounts",
"savings_account_id": "accounts",
"category_id": "categories",
}, label="Abos & Vertraege"),
# Das Logo gehoert zum Vertrag. Klein gerechnet ist es ein paar Kilobyte –
# muehsam nachzutragen waere es trotzdem.
TableSpec("subscription_logos", SubscriptionLogo, "subscription",
{"subscription_id": "subscriptions"}, has_id=False,
label="Vertrags-Logos"),
TableSpec("transactions", Transaction, "owner", { TableSpec("transactions", Transaction, "owner", {
"account_id": "accounts", "account_id": "accounts",
"to_account_id": "accounts", "to_account_id": "accounts",
"category_id": "categories", "category_id": "categories",
"budget_category_id": "categories", "budget_category_id": "categories",
"import_batch_id": "import_batches", "import_batch_id": "import_batches",
"subscription_id": "subscriptions",
}, label="Buchungen"), }, label="Buchungen"),
TableSpec("budget_template_lines", BudgetTemplateLine, "owner", TableSpec("budget_template_lines", BudgetTemplateLine, "owner",
{"category_id": "categories"}, label="Budget-Vorlage"), {"category_id": "categories"}, label="Budget-Vorlage"),
@@ -109,12 +137,69 @@ USER_TABLES: list[TableSpec] = [
{"budget_period_id": "budget_periods", "category_id": "categories"}, {"budget_period_id": "budget_periods", "category_id": "categories"},
label="Budget-Monatswerte"), label="Budget-Monatswerte"),
TableSpec("dashboard_layouts", DashboardLayout, "owner", label="Dashboard-Layout"), TableSpec("dashboard_layouts", DashboardLayout, "owner", label="Dashboard-Layout"),
# target_account_id gehört dazu: Ein Transfer-Schlagwort mit festem
# Zielkonto trug sonst die ALTE Konto-Nummer in die neue Datenbank, und die
# Wiederherstellung brach mit einem Fremdschlüssel-Fehler ab – nicht nur
# diese Zeile, die ganze Sicherung liess sich nicht mehr einspielen.
# accounts steht weiter oben, die Übersetzung greift also.
TableSpec("categorization_rules", CategorizationRule, "owner", TableSpec("categorization_rules", CategorizationRule, "owner",
{"category_id": "categories"}, label="Schlagwortregeln"), {"category_id": "categories", "target_account_id": "accounts"},
label="Schlagwortregeln"),
TableSpec("user_settings", UserSetting, "user", has_id=False, TableSpec("user_settings", UserSetting, "user", has_id=False,
label="Persönliche Einstellungen"), label="Persönliche Einstellungen"),
# Der Eingang MUSS mitgesichert werden: Er hält fest, welche Zahlungen
# bereits von aussen erfasst wurden. Fehlt er nach dem Wiedereinspielen,
# erkennt der nächste Kontoauszug sie nicht wieder und bucht sie ein
# zweites Mal – genau der Fehler, den diese Tabelle verhindern soll.
TableSpec("inbox_items", InboxItem, "owner", {
"account_id": "accounts",
"category_id": "categories",
"transaction_id": "transactions",
}, label="Eingang"),
TableSpec("transfer_links", TransferLink, "owner",
{"from_txn_id": "transactions", "to_txn_id": "transactions"},
label="Übertrags-Verknüpfungen"),
TableSpec("link_ignored", LinkIgnored, "owner",
{"a_txn_id": "transactions", "b_txn_id": "transactions"},
label="Abgelehnte Übertrags-Vorschläge"),
# Erst die Tags, dann die Zuordnung – ein refs-Verweis darf nur auf eine
# schon eingefügte Tabelle zeigen.
TableSpec("tags", Tag, "owner", label="Tags"),
TableSpec("transaction_tags", TransactionTag, "owner",
{"transaction_id": "transactions", "tag_id": "tags"},
label="Tag-Zuordnungen"),
# Ohne die Geräteschlüssel hört der Kurzbefehl auf dem Telefon nach einem
# Wiedereinspielen stillschweigend auf zu funktionieren. Gesichert wird nur
# der Hash – der Klartext existiert nirgends.
TableSpec("api_tokens", ApiToken, "owner",
{"default_account_id": "accounts"}, label="Geräteschlüssel"),
# Aus demselben Grund wie die Geräteschlüssel: Ohne sie kommt nach einem
# Wiedereinspielen niemand mehr über den Anmeldedienst herein, und wer kein
# Passwort hat, gar nicht mehr. Enthält kein Geheimnis – nur die Kennung,
# unter der der Anbieter diesen Menschen führt.
TableSpec("user_identities", UserIdentity, "user", label="SSO-Verknüpfungen"),
] ]
# Bewusst NICHT gesichert:
#
# ``mail_sources``/``mail_senders`` – die Quelle trägt das verschlüsselte
# IMAP-Passwort. Ein Backup ist eine Datei, die der Nutzer herumreicht; dort
# gehört keine Zugangsdatei hinein, auch keine verschlüsselte. Nach einem
# Wiedereinspielen richtet man das Postfach neu ein, das dauert zwei Minuten.
#
# ``pending_uploads`` – dort liegen rohe Dateien, die noch niemand geprüft hat.
# Sie sind flüchtig, blähen ein JSON-Backup um Megabytes auf und lassen sich
# jederzeit erneut vom Telefon schicken.
#
# ``bank_apps``/``bank_connections``/``bank_account_links`` – dieselbe
# Überlegung wie beim Postfach, nur schärfer: ``bank_apps`` trägt den privaten
# Schlüssel, mit dem jede Anfrage an die Bank signiert wird, und
# ``bank_connections`` eine laufende Sitzung. Beides gehört in keine Datei, die
# man weitergibt. Die Sitzung liesse sich ohnehin nicht wiederbeleben – die
# Zustimmung hängt an der Bank, nicht an unserer Datenbank. Nach einem
# Wiedereinspielen verbindet man die Bank neu; die abgerufenen Buchungen selbst
# sind längst gesichert.
# Nur im systemweiten Backup, je Benutzer. # Nur im systemweiten Backup, je Benutzer.
SYSTEM_USER_TABLES: list[TableSpec] = [ SYSTEM_USER_TABLES: list[TableSpec] = [
TableSpec("recovery_codes", RecoveryCode, "user", label="Wiederherstellungscodes"), TableSpec("recovery_codes", RecoveryCode, "user", label="Wiederherstellungscodes"),
@@ -136,6 +221,11 @@ _SKIP_COLUMNS = {"id", "owner_id", "user_id"}
def _dump_value(col, value): def _dump_value(col, value):
if value is None: if value is None:
return None return None
# Binaeres als Base64. Ohne das scheitert das Sichern an der JSON-Ausgabe –
# und zwar erst beim Schreiben, also nachdem der Nutzer schon glaubt, ein
# Backup zu haben.
if isinstance(col.type, LargeBinary) or isinstance(value, (bytes, bytearray)):
return base64.b64encode(bytes(value)).decode("ascii")
if isinstance(col.type, SAEnum): if isinstance(col.type, SAEnum):
return value.value if hasattr(value, "value") else str(value) return value.value if hasattr(value, "value") else str(value)
if isinstance(col.type, DateTime) or isinstance(value, datetime): if isinstance(col.type, DateTime) or isinstance(value, datetime):
@@ -154,6 +244,11 @@ def _dump_value(col, value):
def _load_value(col, value): def _load_value(col, value):
if value is None: if value is None:
return None return None
if isinstance(col.type, LargeBinary):
try:
return base64.b64decode(value)
except (ValueError, TypeError) as exc:
raise BackupError(f"Unlesbares Bild für '{col.key}'") from exc
try: try:
if isinstance(col.type, SAEnum) and col.type.enum_class is not None: if isinstance(col.type, SAEnum) and col.type.enum_class is not None:
return col.type.enum_class(value) return col.type.enum_class(value)
@@ -194,6 +289,10 @@ def _query(db: Session, spec: TableSpec, user: User | None):
q = q.join(BudgetPeriod, spec.model.budget_period_id == BudgetPeriod.id).filter( q = q.join(BudgetPeriod, spec.model.budget_period_id == BudgetPeriod.id).filter(
BudgetPeriod.owner_id == user.id BudgetPeriod.owner_id == user.id
) )
elif spec.scope == "subscription":
q = q.join(Subscription, spec.model.subscription_id == Subscription.id).filter(
Subscription.owner_id == user.id
)
return q return q
@@ -318,8 +417,22 @@ _MERGE_KEYS: dict[str, Callable[[dict], tuple | None]] = {
"import_templates": lambda r: ("name", (r.get("name") or "").strip().lower()), "import_templates": lambda r: ("name", (r.get("name") or "").strip().lower()),
"import_batches": lambda r: ("batch", r.get("account_id"), r.get("filename"), "import_batches": lambda r: ("batch", r.get("account_id"), r.get("filename"),
r.get("created_at")), r.get("created_at")),
# Die Kennung der Quelle zuerst: Sie ist die einzige EXAKTE Aussage, dass
# zwei Zeilen dieselbe Zahlung sind – und ohne sie liefe „Ergaenzen" gegen
# die Eindeutigkeit von (owner_id, source_uid), statt die Zeile zu
# erkennen. Erst danach der Aehnlichkeits-Schluessel.
"subscriptions": lambda r: ("abo", (r.get("name") or "").strip().lower()),
# Ohne diesen Eintrag legt "Ergaenzen" das Logo eines schon vorhandenen
# Vertrags ein zweites Mal an – und scheitert an seinem Schluessel, womit
# die ganze Wiederherstellung kippt. Es gibt genau eins je Vertrag.
"subscription_logos": lambda r: ("logo", r.get("subscription_id")),
# Ohne die beiden Einträge scheitert ein zweiter Ergänzen-Lauf an
# (owner_id, name) bzw. (transaction_id, tag_id).
"tags": lambda r: ("name", (r.get("name") or "").strip().lower()),
"transaction_tags": lambda r: ("zuordnung", r.get("transaction_id"), r.get("tag_id")),
"transactions": lambda r: ( "transactions": lambda r: (
("hash", r["import_hash"]) if r.get("import_hash") ("uid", r["source_uid"]) if r.get("source_uid")
else ("hash", r["import_hash"]) if r.get("import_hash")
else ("nat", r.get("date"), r.get("account_id"), r.get("amount_cents"), else ("nat", r.get("date"), r.get("account_id"), r.get("amount_cents"),
(r.get("payee") or "").strip().lower()) (r.get("payee") or "").strip().lower())
), ),
@@ -339,18 +452,24 @@ _ALL_OR_NOTHING = {
} }
def _existing_keys(db: Session, spec: TableSpec, user: User) -> dict[tuple, int]: def _existing_keys(db: Session, spec: TableSpec, user: User) -> dict[tuple, int | None]:
"""Vergleichsschlüssel der schon vorhandenen Zeilen -> deren ID.""" """Vergleichsschlüssel der schon vorhandenen Zeilen -> deren ID.
Bei Tabellen mit natürlichem Schlüssel (``has_id=False``) gibt es keine
``id`` – dort zählt allein, DASS die Zeile schon da ist. Der Wert bleibt
dann leer; gebraucht wird er nur für die Verweis-Übersetzung, und die
betrifft solche Tabellen nicht.
"""
schluessel = _MERGE_KEYS.get(spec.key) schluessel = _MERGE_KEYS.get(spec.key)
if schluessel is None: if schluessel is None:
return {} return {}
cols = _columns(spec) cols = _columns(spec)
treffer: dict[tuple, int] = {} treffer: dict[tuple, int | None] = {}
for obj in _query(db, spec, user).all(): for obj in _query(db, spec, user).all():
roh = {c.key: _dump_value(c, getattr(obj, c.key)) for c in cols} roh = {c.key: _dump_value(c, getattr(obj, c.key)) for c in cols}
k = schluessel(roh) k = schluessel(roh)
if k is not None: if k is not None:
treffer.setdefault(k, obj.id) treffer.setdefault(k, obj.id if spec.has_id else None)
return treffer return treffer
@@ -406,7 +525,7 @@ def _insert_rows(
if merge and schluessel is not None: if merge and schluessel is not None:
k = schluessel(aufgeloest) k = schluessel(aufgeloest)
if k is not None: if k is not None:
vorhanden[k] = obj.id vorhanden[k] = obj.id if spec.has_id else None
neu += 1 neu += 1
return neu return neu
+89 -24
View File
@@ -1,6 +1,7 @@
"""Kontostände (in Kontowährung) und Umrechnung in die Hauptwährung. """Kontostände (in Kontowährung) und Umrechnung in die Hauptwährung.
Saldo = Anfangssaldo + Einnahmen - Ausgaben - Transfers-raus + Transfers-rein. Saldo = Anfangssaldo + Einnahmen - Ausgaben - Transfers-raus + Transfers-rein,
gezählt ab dem Stichtag des Kontos (siehe ``counts_after_opening``).
Transfers werden in der jeweiligen Kontowährung verrechnet (bei währungsüber- Transfers werden in der jeweiligen Kontowährung verrechnet (bei währungsüber-
greifenden Transfers am Zielkonto der gutgeschriebene Betrag ``to_amount_cents``). greifenden Transfers am Zielkonto der gutgeschriebene Betrag ``to_amount_cents``).
""" """
@@ -18,30 +19,94 @@ def _sum(query) -> int:
return int(query.scalar() or 0) return int(query.scalar() or 0)
def counts_after_opening(account: Account):
"""Bedingung, die Buchungen bis zum Stichtag ausschliesst.
Der Anfangssaldo beschreibt den Stand *am Ende* des Stichtags – so steht er
auf jedem Auszug und im E-Banking. Buchungen bis einschliesslich dieses Tages
stecken also schon darin und dürfen nicht noch einmal zählen. Sie bleiben in
der Liste und in den Auswertungen, nur der Kontostand lässt sie aus.
Ohne Stichtag zählt alles – dann ist der Anfangssaldo der Stand von Beginn an.
"""
if account.opening_date is None:
return None
return Transaction.date > account.opening_date
def aufschluesselung(db: Session, account: Account) -> dict:
"""Woraus sich der Saldo zusammensetzt – Teil fuer Teil.
Dieselbe Rechnung wie ``account_balance_cents``, nur offen gelegt. Beide
aus EINER Quelle, damit die Anzeige nicht etwas anderes behauptet als der
Saldo: Stimmt eine Zahl nicht, sieht man hier, welche.
"""
ab = counts_after_opening(account)
def teil(spalte, *bedingungen) -> dict:
frage = db.query(func.sum(spalte), func.count()).filter(*bedingungen)
if ab is not None:
frage = frage.filter(ab)
summe, anzahl = frage.one()
return {"cents": int(summe or 0), "anzahl": int(anzahl or 0)}
teile = {
"einnahmen": teil(
Transaction.amount_cents,
Transaction.account_id == account.id, Transaction.type == TxnType.income),
"ausgaben": teil(
Transaction.amount_cents,
Transaction.account_id == account.id, Transaction.type == TxnType.expense),
"uebertraege_raus": teil(
Transaction.amount_cents,
Transaction.account_id == account.id, Transaction.type == TxnType.transfer),
"uebertraege_rein": teil(
func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents),
Transaction.to_account_id == account.id,
Transaction.type == TxnType.transfer),
}
teile["anfangsbestand"] = {"cents": account.opening_balance_cents, "anzahl": 0}
teile["saldo"] = {
"cents": (account.opening_balance_cents
+ teile["einnahmen"]["cents"] - teile["ausgaben"]["cents"]
- teile["uebertraege_raus"]["cents"] + teile["uebertraege_rein"]["cents"]),
"anzahl": 0,
}
# Was der Stichtag ausschliesst. Diese Buchungen stehen in der Liste und in
# den Auswertungen, zaehlen aber nicht in den Kontostand – sie stecken schon
# im Anfangsbestand. Ohne diese Angabe ist der Unterschied zwischen dem, was
# man sieht, und dem, was gerechnet wird, nicht zu erklaeren.
if account.opening_date is not None:
bis = Transaction.date <= account.opening_date
def vorher(spalte, *bedingungen) -> tuple[int, int]:
summe, anzahl = (db.query(func.sum(spalte), func.count())
.filter(*bedingungen).filter(bis).one())
return int(summe or 0), int(anzahl or 0)
ein, n1 = vorher(Transaction.amount_cents,
Transaction.account_id == account.id,
Transaction.type == TxnType.income)
aus, n2 = vorher(Transaction.amount_cents,
Transaction.account_id == account.id,
Transaction.type == TxnType.expense)
raus, n3 = vorher(Transaction.amount_cents,
Transaction.account_id == account.id,
Transaction.type == TxnType.transfer)
rein, n4 = vorher(func.coalesce(Transaction.to_amount_cents,
Transaction.amount_cents),
Transaction.to_account_id == account.id,
Transaction.type == TxnType.transfer)
teile["vor_stichtag"] = {"cents": ein - aus - raus + rein,
"anzahl": n1 + n2 + n3 + n4}
else:
teile["vor_stichtag"] = {"cents": 0, "anzahl": 0}
teile["stichtag"] = account.opening_date.isoformat() if account.opening_date else None
return teile
def account_balance_cents(db: Session, account: Account) -> int: def account_balance_cents(db: Session, account: Account) -> int:
income = _sum( return aufschluesselung(db, account)["saldo"]["cents"]
db.query(func.sum(Transaction.amount_cents)).filter(
Transaction.account_id == account.id, Transaction.type == TxnType.income
)
)
expense = _sum(
db.query(func.sum(Transaction.amount_cents)).filter(
Transaction.account_id == account.id, Transaction.type == TxnType.expense
)
)
transfer_out = _sum(
db.query(func.sum(Transaction.amount_cents)).filter(
Transaction.account_id == account.id, Transaction.type == TxnType.transfer
)
)
transfer_in = _sum(
db.query(
func.sum(func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents))
).filter(
Transaction.to_account_id == account.id, Transaction.type == TxnType.transfer
)
)
return account.opening_balance_cents + income - expense - transfer_out + transfer_in
def account_balance_base_cents( def account_balance_base_cents(
+184
View File
@@ -0,0 +1,184 @@
"""Zugriff auf Bankkonten über PSD2 (Enable Banking).
Seit PSD2 müssen EU-Banken eine Schnittstelle anbieten, aber nur für
lizenzierte Dienste mit eIDAS-Zertifikat. Enable Banking hält diese Lizenz und
reicht sie weiter: Man registriert eine eigene Anwendung, verknüpft die eigenen
Konten und darf danach genau diese lesen („Restricted Mode").
Es gibt keinen API-Schlüssel. Jede Anfrage trägt ein selbst gebautes JWT,
signiert mit dem privaten Schlüssel der Anwendung (RS256, eine Stunde gültig).
Signiert wird mit ``cryptography``, das ohnehin schon für die Verschlüsselung
der Zugangsdaten im Projekt ist – keine zusätzliche Abhängigkeit.
Nur Lesen: Salden und Umsätze. Zahlungen auslösen kann diese Anbindung nicht,
und das soll auch so bleiben.
"""
from __future__ import annotations
import base64
import json
import logging
import time
from datetime import date, datetime, timedelta, timezone
import httpx
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
log = logging.getLogger(__name__)
BASIS = "https://api.enablebanking.com"
JWT_GUELTIG_SEKUNDEN = 3600
ZEITLIMIT = 30.0
# Wie weit ein erster Abruf zurückgeht. Mehr geben die meisten Banken über
# PSD2 ohnehin nicht heraus.
ERSTER_ABRUF_TAGE = 90
class BankFehler(RuntimeError):
"""Fehler beim Zugriff – mit einer Ursache, die man dem Nutzer zeigen kann."""
def _b64(roh: bytes) -> str:
"""URL-sicheres Base64 ohne Füllzeichen, wie es JWT verlangt."""
return base64.urlsafe_b64encode(roh).rstrip(b"=").decode("ascii")
def baue_jwt(application_id: str, private_key_pem: str) -> str:
"""Signiertes JWT für eine Stunde.
Kopf und Rumpf sind von Enable Banking vorgegeben; ``kid`` ist die
Application ID, an der die Gegenseite den öffentlichen Schlüssel findet.
"""
try:
schluessel = serialization.load_pem_private_key(
private_key_pem.encode("utf-8"), password=None
)
except (ValueError, TypeError) as e:
raise BankFehler(
"Der private Schlüssel lässt sich nicht lesen. Ist es die "
"unveränderte .pem-Datei aus dem Control Panel?"
) from e
if not isinstance(schluessel, rsa.RSAPrivateKey):
raise BankFehler("Der Schlüssel ist kein RSA-Schlüssel.")
jetzt = int(time.time())
kopf = {"typ": "JWT", "alg": "RS256", "kid": application_id}
rumpf = {
"iss": "enablebanking.com",
"aud": "api.enablebanking.com",
"iat": jetzt,
"exp": jetzt + JWT_GUELTIG_SEKUNDEN,
}
zu_signieren = f"{_b64(json.dumps(kopf).encode())}.{_b64(json.dumps(rumpf).encode())}"
signatur = schluessel.sign(
zu_signieren.encode("ascii"), padding.PKCS1v15(), hashes.SHA256()
)
return f"{zu_signieren}.{_b64(signatur)}"
class BankClient:
"""Dünne Hülle um die vier Endpunkte, die wir brauchen.
Als Klasse und nicht als Funktionssammlung, damit das JWT je Abruf-Runde
einmal gebaut wird statt je Anfrage – und damit die Tests einen
Doppelgänger einsetzen können, ohne dass ein Netzzugang nötig ist.
"""
def __init__(self, application_id: str, private_key_pem: str, basis: str = BASIS):
self._jwt = baue_jwt(application_id, private_key_pem)
self._basis = basis.rstrip("/")
def _anfrage(self, methode: str, pfad: str, **kw) -> dict:
kopfzeilen = {"Authorization": f"Bearer {self._jwt}",
"Content-Type": "application/json"}
try:
with httpx.Client(timeout=ZEITLIMIT) as c:
antwort = c.request(methode, f"{self._basis}{pfad}",
headers=kopfzeilen, **kw)
except httpx.HTTPError as e:
raise BankFehler(f"Die Bank-Schnittstelle ist nicht erreichbar: {e}") from e
if antwort.status_code >= 400:
raise BankFehler(_fehlertext(antwort))
try:
return antwort.json()
except ValueError as e:
raise BankFehler("Unerwartete Antwort der Schnittstelle.") from e
# --- Die vier Endpunkte -------------------------------------------------
def banken(self, land: str | None = None) -> list[dict]:
"""Verfügbare Banken – eines Landes oder alle.
Ohne Land liefert die Schnittstelle die vollständige Liste. Das ist
nötig, weil das Sitzland einer Bank nicht an der IBAN ablesbar ist:
Revolut gibt deutsche IBANs aus, ist aber in Litauen ansässig.
"""
pfad = f"/aspsps?country={land.upper()}" if land else "/aspsps"
daten = self._anfrage("GET", pfad)
# Defensiv: manche Fassungen liefern die Liste direkt statt unter
# einem Schlüssel. Eine leere Antwort stillschweigend als „keine
# Treffer" auszugeben hat schon genug Zeit gekostet.
if isinstance(daten, list):
return daten
return daten.get("aspsps") or daten.get("data") or []
def starte_autorisierung(self, aspsp_name: str, land: str, redirect_url: str,
state: str, tage: int, psu_type: str = "personal") -> dict:
"""Weiterleitungs-Adresse für die Zustimmung bei der Bank."""
gueltig_bis = datetime.now(timezone.utc) + timedelta(days=tage)
return self._anfrage("POST", "/auth", json={
"access": {"valid_until": gueltig_bis.isoformat()},
"aspsp": {"name": aspsp_name, "country": land.upper()},
"redirect_url": redirect_url,
"state": state,
"psu_type": psu_type,
})
def sitzung(self, code: str) -> dict:
"""Den Rückleitungs-Code gegen eine Sitzung samt Kontoliste tauschen."""
return self._anfrage("POST", "/sessions", json={"code": code})
def salden(self, konto_uid: str) -> list[dict]:
return self._anfrage("GET", f"/accounts/{konto_uid}/balances").get("balances", [])
def umsaetze(self, konto_uid: str, seit: date) -> list[dict]:
"""Alle Umsätze ab einem Datum – über alle Seiten hinweg.
Die Schnittstelle blättert über einen ``continuation_key``. Ohne diese
Schleife bekäme man stillschweigend nur die erste Seite; bei einem
Jahresauszug wäre der Rest einfach weg.
"""
gesammelt: list[dict] = []
weiter: str | None = None
for _ in range(50): # Bremse gegen eine endlose Antwortkette
pfad = f"/accounts/{konto_uid}/transactions?date_from={seit.isoformat()}"
if weiter:
pfad += f"&continuation_key={weiter}"
daten = self._anfrage("GET", pfad)
gesammelt.extend(daten.get("transactions", []))
weiter = daten.get("continuation_key")
if not weiter:
break
else:
log.warning("Umsatzabruf nach 50 Seiten abgebrochen (Konto %s)", konto_uid)
return gesammelt
def _fehlertext(antwort: httpx.Response) -> str:
"""Aus der Fehlerantwort etwas machen, das im Formular hilft."""
if antwort.status_code == 401:
return ("Die Anmeldung wurde abgelehnt. Passen Application ID und "
"Schlüsseldatei zusammen, und ist die Anwendung aktiv?")
if antwort.status_code == 403:
return ("Zugriff verweigert. Im eingeschränkten Betrieb sind nur die "
"Konten lesbar, die du selbst verknüpft hast.")
try:
inhalt = antwort.json()
grund = inhalt.get("message") or inhalt.get("error") or inhalt.get("detail")
if grund:
return f"Die Bank-Schnittstelle meldet: {grund}"
except ValueError:
pass
return f"Die Bank-Schnittstelle antwortete mit Fehler {antwort.status_code}."
+847
View File
@@ -0,0 +1,847 @@
"""Bankkonten verbinden und Umsätze abholen.
Der Weg in drei Schritten, genau wie ihn die Schnittstelle vorgibt:
1. ``starte_verbindung`` → Adresse, zu der der Nutzer geschickt wird
2. ``schliesse_verbindung`` → der Rückleitungs-Code wird zur Sitzung,
und wir erfahren, welche Konten es dort gibt
3. ``hole_umsaetze`` → Umsätze je zugeordnetem Konto
Abgeholte Umsätze werden NICHT gebucht. Sie landen als Vorschlag im Eingang,
dieselbe Strecke wie Mails und Kurzbefehl-Erfassungen – und laufen dabei durch
``detect_rows``, sodass Duplikatschutz und Schlagwortregeln ohne
Sonderbehandlung greifen. Eine dritte Quelle, kein dritter Sonderweg.
"""
from __future__ import annotations
import json
import logging
import secrets
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..models import (
Account,
BankAccountLink,
BankApp,
BankConnection,
InboxItem,
InboxSource,
InboxStatus,
Transaction,
TxnType,
User,
)
from . import csv_import, eingang, geheimnis
from .bank_api import ERSTER_ABRUF_TAGE, BankClient, BankFehler
log = logging.getLogger(__name__)
# Wie lange die Zustimmung gelten soll. Die Bank kürzt selbst, wenn sie weniger
# erlaubt; mehr als 180 Tage lässt die Regelung nicht zu.
ZUSTIMMUNG_TAGE = 180
# Wie weit jeder weitere Abruf hinter den letzten zurueckgreift.
RUECKGRIFF_TAGE = 7
# Kuerzester Takt fuer den automatischen Abruf. PSD2 verpflichtet die Banken zu
# VIER unbeaufsichtigten Abfragen je Konto und Tag – haeufiger duerfen sie
# ablehnen, und N26 tut das ("Too many requests"). Sechs Stunden sind genau
# diese vier. Von Hand abrufen darf man weiterhin so oft man will: Dabei sitzt
# der Nutzer davor, und dafuer gilt die Grenze nicht.
MIN_TAKT_MINUTEN = 360
# Ab wann gewarnt wird, dass neu autorisiert werden muss. Läuft die Zustimmung
# ab, hört der Abruf sonst stumm auf – dieselbe Falle wie „0 neu" ohne Grund.
WARNUNG_TAGE = 14
def _client(db: Session, user: User) -> BankClient:
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
if app is None:
raise BankFehler(
"Es ist noch keine Bank-Anwendung hinterlegt. Trag Application ID "
"und Schlüsseldatei in den Einstellungen ein."
)
schluessel = geheimnis.entschluesseln(app.private_key_enc)
if schluessel is None:
raise BankFehler(
"Der gespeicherte Schlüssel lässt sich nicht mehr entschlüsseln. "
"Wurde das Server-Geheimnis getauscht? Dann bitte neu hinterlegen."
)
return BankClient(app.application_id, schluessel)
# Nicht die ganze Liste ins Formular kippen – über alle Länder sind es
# tausende. Wer sucht, bekommt Treffer; wer ein Land wählt, dessen Banken.
MAX_TREFFER = 200
def _rang(name: str, begriff: str, in_name: bool) -> int:
"""Wie gut passt ein Name zum Suchbegriff? Kleiner ist besser.
Ohne Rangfolge entscheidet das Alphabet, welche Treffer die Obergrenze
überleben – und dann fällt „N26" bei der Suche nach „n" hinter 200 Banken
heraus, deren Name irgendwo ein n enthält. Genau dieser Fehler hat hier
einen Abend gekostet.
"""
klein = name.lower()
if klein == begriff:
return 0
if klein.startswith(begriff):
return 1
if any(wort.startswith(begriff) for wort in klein.replace("-", " ").split()):
return 2
if begriff in klein:
return 3
return 4 if not in_name else 3
def banken(db: Session, user: User, land: str | None = None,
suche: str | None = None) -> dict:
"""Banken suchen – in einem Land oder über alle hinweg.
Die Suche über alle Länder ist kein Luxus: Das Sitzland einer Bank steht
nicht in der IBAN. Revolut gibt deutsche IBANs aus, ist aber in Litauen
ansässig – wer unter „Deutschland" sucht, findet es dort nie.
"""
roh = _client(db, user).banken(land)
begriff = (suche or "").strip().lower()
passend = []
for b in roh:
name = b.get("name")
if not name:
continue
name = str(name)
# Auch BIC und Gruppenname durchsuchen: Banken heissen in der Liste
# nicht immer so wie im Alltag.
neben = " ".join(str(x) for x in (b.get("bic"), b.get("group")) if x).lower()
if begriff and begriff not in name.lower() and begriff not in neben:
continue
passend.append({
"name": name, "country": b.get("country"), "logo": b.get("logo"),
"psu_types": b.get("psu_types", []), "beta": bool(b.get("beta")),
"_rang": _rang(name, begriff, True) if begriff else 3,
})
# Erst nach Güte, dann alphabetisch – und ERST DANN kürzen.
passend.sort(key=lambda b: (b["_rang"], b["name"].lower(), b.get("country") or ""))
gezeigt = [{k: v for k, v in b.items() if k != "_rang"} for b in passend[:MAX_TREFFER]]
namen = sorted({str(b.get("name")) for b in roh if b.get("name")})
beispiele = (namen[:3] + namen[len(namen) // 2: len(namen) // 2 + 2] + namen[-3:]
if len(namen) > 8 else namen)
return {
"gesamt": len(roh),
# Die ECHTE Trefferzahl, nicht die gekürzte. Sonst steht dort immer
# „200" und man haelt die Obergrenze für ein Suchergebnis.
"treffer_gesamt": len(passend),
"treffer": gezeigt,
"gekuerzt": len(passend) > MAX_TREFFER,
"beispiele": beispiele,
"von": namen[0] if namen else None,
"bis": namen[-1] if namen else None,
"anfangsbuchstaben": sorted({n[:1].upper() for n in namen}),
"gleicher_buchstabe": (
[n for n in namen if begriff and n[:1].lower() == begriff[:1]][:40]
if begriff else []
),
}
def starte_verbindung(db: Session, user: User, aspsp_name: str, land: str,
psu_type: str = "personal") -> dict:
"""Autorisierung anstossen und die Weiterleitungs-Adresse liefern."""
app = db.query(BankApp).filter(BankApp.owner_id == user.id).first()
if app is None:
raise BankFehler("Es ist noch keine Bank-Anwendung hinterlegt.")
state = secrets.token_urlsafe(24)
# Dieselbe Bank erneut verbinden frischt den bestehenden Eintrag auf, statt
# einen zweiten anzulegen. Sonst müsste man vorher löschen – und dabei
# gingen die Kontozuordnungen mit verloren, obwohl man sie behalten will.
verbindung = (
db.query(BankConnection)
.filter(BankConnection.owner_id == user.id,
BankConnection.aspsp_name == aspsp_name,
BankConnection.aspsp_country == land.upper())
.order_by(BankConnection.id.desc())
.first()
)
if verbindung is None:
verbindung = BankConnection(
owner_id=user.id, aspsp_name=aspsp_name, aspsp_country=land.upper(),
)
db.add(verbindung)
verbindung.pending_state = state
verbindung.last_error = None
db.flush()
antwort = _client(db, user).starte_autorisierung(
aspsp_name, land, f"{app.base_url.rstrip('/')}/bank/rueckkehr",
state, ZUSTIMMUNG_TAGE, psu_type,
)
db.commit()
return {"url": antwort.get("url"), "connection_id": verbindung.id, "state": state}
# Wie viel des uebermittelten Datensatzes aufgehoben wird. Ein Konto sind ein
# paar hundert Zeichen; die Grenze ist nur da, damit eine ungewoehnlich
# gespraechige Bank die Zeile nicht sprengt.
ROHDATEN_MAX = 4000
def _merkmale(konto: dict) -> dict:
"""Woran sich ein Bankkonto erkennen laesst.
Ein Revolut-Pocket bringt weder eigene IBAN noch eigenen Namen mit. Ohne
Produktbezeichnung und Ersatzkennung sehen mehrere Konten derselben Bank
identisch aus – man kann sie dann weder unterscheiden noch zuordnen.
"""
kennung = konto.get("account_id") or {}
iban = kennung.get("iban")
# PSD2 fuehrt Konten ohne IBAN unter ``other``; manche Banken liefern
# stattdessen nur die Sammelliste.
andere = (kennung.get("other") or {}).get("identification")
if not andere:
for eintrag in konto.get("all_account_ids") or []:
wert = (eintrag or {}).get("identification")
if wert and wert != iban:
andere = wert
break
produkt = konto.get("product") or konto.get("cash_account_type")
# Reihenfolge nach Aussagekraft. Die uid steht bewusst am Ende: Sie ist
# eindeutig, sagt aber niemandem, welches Konto gemeint ist.
name = (konto.get("name") or konto.get("details") or produkt
or andere or iban or konto.get("uid"))
return {
"iban": str(iban)[:64] if iban else None,
"other_id": str(andere)[:128] if andere else None,
"product": str(produkt)[:120] if produkt else None,
"remote_name": str(name)[:255] if name else None,
"currency": (konto.get("currency") or "EUR").upper()[:3],
"raw_json": json.dumps(konto, ensure_ascii=False, default=str)[:ROHDATEN_MAX],
}
def schliesse_verbindung(db: Session, user: User, state: str, code: str) -> dict:
"""Code gegen die Sitzung tauschen und die gefundenen Konten ablegen.
Die Konten werden nur VERZEICHNET, nicht zugeordnet: Welches Bankkonto zu
welchem Konto in Certain gehört, entscheidet der Nutzer. Raten wäre hier
besonders teuer, weil eine falsche Zuordnung fremde Umsätze in ein Konto
schreiben würde.
"""
verbindung = (
db.query(BankConnection)
.filter(BankConnection.owner_id == user.id, BankConnection.pending_state == state)
.first()
)
if verbindung is None:
raise BankFehler("Zu dieser Rückmeldung gibt es keine offene Verbindung.")
daten = _client(db, user).sitzung(code)
verbindung.session_id = daten.get("session_id")
verbindung.pending_state = None
verbindung.last_error = None
gueltig = (daten.get("access") or {}).get("valid_until")
if gueltig:
try:
verbindung.valid_until = datetime.fromisoformat(gueltig.replace("Z", "+00:00"))
except ValueError:
verbindung.valid_until = None
uebermittelt = daten.get("accounts") or []
neu = bekannt = 0
uebergangen: list[dict] = []
for konto in uebermittelt:
uid = konto.get("uid")
if not uid:
uebergangen.append(konto)
# Nicht stillschweigend verwerfen: Sonst sieht „3 Konten" genauso
# aus, als hätte die Bank nur drei geschickt – und man sucht den
# Fehler bei der Bank statt hier.
continue
vorhanden = (
db.query(BankAccountLink)
.filter(BankAccountLink.owner_id == user.id, BankAccountLink.remote_uid == uid)
.first()
)
merkmale = _merkmale(konto)
if vorhanden is not None:
# Schon bekannt – die Zuordnung bleibt bewusst erhalten. Die
# Merkmale werden aber aufgefrischt, sonst muesste man loeschen,
# um an eine spaetere Produktbezeichnung zu kommen.
vorhanden.connection_id = verbindung.id
for feld, wert in merkmale.items():
setattr(vorhanden, feld, wert)
bekannt += 1
continue
db.add(BankAccountLink(
owner_id=user.id, connection_id=verbindung.id, remote_uid=uid, **merkmale,
))
neu += 1
db.commit()
return {
"connection_id": verbindung.id,
"konten": neu + bekannt, # nutzbar, egal ob neu oder schon bekannt
"neu": neu,
"bekannt": bekannt,
"uebermittelt": len(uebermittelt),
"ohne_kennung": len(uebergangen),
# Was genau übergangen wurde. Ohne das bleibt die Frage offen, ob die
# Bank ein Konto gar nicht schickt oder Certain es nicht versteht.
"uebergangen": [
{k: v for k, v in konto.items() if k != "identification_hashes"}
for konto in uebergangen[:20]
],
"valid_until": verbindung.valid_until.isoformat() if verbindung.valid_until else None,
}
def _betrag_cents(umsatz: dict) -> int | None:
"""Vorzeichenbehafteter Betrag in Cent.
PSD2 führt den Betrag ohne Vorzeichen und die Richtung getrennt in
``credit_debit_indicator`` (CRDT/DBIT). Manche Banken liefern trotzdem ein
Vorzeichen mit – beides wird berücksichtigt, damit eine Ausgabe nicht als
Einnahme ankommt.
"""
betrag = (umsatz.get("transaction_amount") or {}).get("amount")
if betrag is None:
return None
cents = csv_import.parse_amount(str(betrag), ".")
if cents is None:
return None
richtung = (umsatz.get("credit_debit_indicator") or "").upper()
if richtung == "DBIT":
return -abs(cents)
if richtung == "CRDT":
return abs(cents)
return cents
def _betrag_mit_waehrung(wert) -> tuple[int, str] | None:
"""Aus ``{"amount": "12.00", "currency": "EUR"}`` Cent und Waehrung."""
if not isinstance(wert, dict):
return None
cents = csv_import.parse_amount(str(wert.get("amount")), ".")
waehrung = wert.get("currency")
if cents is None or not waehrung:
return None
return abs(cents), str(waehrung).upper()[:3]
def _umrechnung(umsatz: dict, waehrung: str | None) -> tuple[float | None, int | None, str | None]:
"""Kurs und Gegenbetrag, wie die Bank sie mitteilt.
Die Feldnamen unterscheiden sich je Anbindung, deshalb werden mehrere
Formen abgeklopft – und es wird still NICHTS gesetzt, wenn keine passt.
Raten waere hier schlimmer als die EZB-Naeherung, denn ein falscher Kurs
sieht aus wie ein richtiger.
"""
wechsel = umsatz.get("currency_exchange")
if isinstance(wechsel, list):
wechsel = next((w for w in wechsel if isinstance(w, dict)), None)
if not isinstance(wechsel, dict):
wechsel = {}
kurs = None
for feld in ("exchange_rate", "rate", "unit_rate"):
roh = wechsel.get(feld) if wechsel else None
if roh is None:
roh = umsatz.get(feld)
if roh is None:
continue
try:
wert = float(str(roh).replace(",", "."))
except (TypeError, ValueError):
continue
if wert > 0:
kurs = wert
break
# Der Betrag in der ANDEREN Waehrung. Nur brauchbar, wenn er sich von der
# Waehrung des Umsatzes unterscheidet – sonst ist es derselbe Betrag.
gegen = None
for quelle in (wechsel, umsatz):
for feld in ("instructed_amount", "original_amount", "source_amount"):
treffer = _betrag_mit_waehrung((quelle or {}).get(feld))
if treffer and treffer[1] != (waehrung or "").upper():
gegen = treffer
break
if gegen:
break
return kurs, (gegen[0] if gegen else None), (gegen[1] if gegen else None)
def _text(umsatz: dict, ausgabe: bool) -> tuple[str | None, str | None]:
"""Empfänger und Verwendungszweck. Bei einer Ausgabe zählt der Empfänger,
bei einer Einnahme der Absender."""
gegen = umsatz.get("creditor") if ausgabe else umsatz.get("debtor")
name = (gegen or {}).get("name")
zweck = umsatz.get("remittance_information")
if isinstance(zweck, list):
zweck = " ".join(str(z) for z in zweck if z)
return (str(name)[:255] if name else None,
str(zweck)[:255] if zweck else None)
# Saldo-Arten nach Aussagekraft. Gebucht schlaegt verfuegbar: Certain rechnet
# mit gebuchten Bewegungen. Ein "verfuegbarer" Saldo enthaelt Vorgemerktes oder
# einen Ueberziehungsrahmen und waere mit der eigenen Rechnung nicht vergleichbar.
SALDO_RANG = ("CLBD", "ITBD", "PRCD", "ITAV", "XPCD", "FWAV", "OTHR")
# Nur diese Arten zaehlen gebuchte Bewegungen – und nur die sind mit Certains
# Rechnung vergleichbar. Ein "verfuegbarer" Saldo enthaelt Vorgemerktes, das
# Certain bewusst nicht bucht; eine Abweichung daraus waere keine.
GEBUCHTE_SALDEN = {"CLBD", "ITBD", "PRCD"}
def saldo_vergleichbar(art: str | None) -> bool:
return (art or "").upper() in GEBUCHTE_SALDEN
def _zeitpunkt(saldo: dict) -> datetime | None:
"""Wann der Saldo galt. Ohne Zeitpunkt ist eine Abweichung nicht deutbar –
ein Saldo von gestern darf von heute abweichen."""
roh = saldo.get("last_change_date_time") or saldo.get("reference_date")
if not roh:
return None
try:
wert = datetime.fromisoformat(str(roh).replace("Z", "+00:00"))
except ValueError:
return None
return wert if wert.tzinfo else wert.replace(tzinfo=timezone.utc)
def _saldo(salden: list[dict]) -> tuple[int | None, str | None, datetime | None]:
"""Der aussagekraeftigste der gemeldeten Saldi.
Banken liefern mehrere nebeneinander. Welcher gemeint ist, steht in
``balance_type``; einfach den ersten zu nehmen waere Zufall.
"""
beste = None
for eintrag in salden or []:
art = str(eintrag.get("balance_type") or "").upper()
betrag = (eintrag.get("balance_amount") or {}).get("amount")
if betrag is None:
continue
cents = csv_import.parse_amount(str(betrag), ".")
if cents is None:
continue
rang = SALDO_RANG.index(art) if art in SALDO_RANG else len(SALDO_RANG)
if beste is None or rang < beste[0]:
beste = (rang, cents, art or None, _zeitpunkt(eintrag))
if beste is None:
return None, None, None
return beste[1], beste[2], beste[3]
def _verknuepfungen(db: Session, user: User, verbindung: BankConnection,
nur_link_id: int | None) -> list[BankAccountLink]:
"""Die Konten einer Verbindung – oder genau eines davon.
Ein Abruf bucht. Wer ein einzelnes Konto abruft, will nicht, dass nebenbei
auf einem zweiten Buchungen entstehen.
"""
frage = db.query(BankAccountLink).filter(
BankAccountLink.connection_id == verbindung.id,
BankAccountLink.owner_id == user.id,
)
if nur_link_id is not None:
frage = frage.filter(BankAccountLink.id == nur_link_id)
return frage.all()
def hole_salden(db: Session, user: User, verbindung: BankConnection,
client: BankClient | None = None,
nur_link_id: int | None = None) -> int:
"""Den von der Bank gefuehrten Saldo je Konto auffrischen.
Auch fuer noch nicht zugeordnete Konten: Bei Revolut-Pockets ist der Betrag
oft das Einzige, woran man erkennt, welches Pocket man vor sich hat.
Ein Fehler bei einem Konto beendet den Durchlauf nicht. Sonst blieben wegen
eines nicht freigegebenen Kontos alle uebrigen Saldi leer.
"""
client = client or _client(db, user)
aktualisiert = 0
for verknuepfung in _verknuepfungen(db, user, verbindung, nur_link_id):
try:
cents, art, stand = _saldo(client.salden(verknuepfung.remote_uid))
except BankFehler as e:
log.warning("Saldo fuer %s nicht abrufbar: %s", verknuepfung.remote_uid, e)
continue
if cents is None:
continue
verknuepfung.balance_cents = cents
verknuepfung.balance_type = art
verknuepfung.balance_at = stand
aktualisiert += 1
db.commit()
return aktualisiert
# Nur abgeschlossene Umsaetze zaehlen fuer den Saldo. Vorgemerkte kommen ein
# oder zwei Tage spaeter noch einmal – dann mit anderer Kennung, weil die Bank
# beim Buchen eine neue vergibt. Beides aufzunehmen hiesse: doppelt gebucht.
GEBUCHT = {"BOOK", "BOOKED"}
VORGEMERKT = {"PDNG", "PENDING"}
def _ist_gebucht(umsatz: dict) -> bool | None:
"""True = gebucht, False = vorgemerkt, None = weder noch (storniert o. ae.).
Fehlt die Angabe, gilt der Umsatz als gebucht: Die meisten Anbindungen
liefern ueber diesen Endpunkt ohnehin nur gebuchte.
"""
status = str(umsatz.get("status") or "").upper()
if not status or status in GEBUCHT:
return True
if status in VORGEMERKT:
return False
return None
def _startdatum(db: Session, user: User, konto: Account,
verknuepfung: BankAccountLink, tage: int | None = None) -> date:
"""Ab welchem Tag Umsaetze DIESES Kontos geholt werden.
Beim ERSTEN Abruf ab dem Tag nach der neuesten vorhandenen Buchung des
Kontos. Ohne das holte die Bank neunzig Tage zurueck – mitten in Monate,
die schon per CSV eingelesen sind. Der Duplikatschutz faende die nicht:
Sein Schluessel enthaelt den Empfaengertext, und der lautet bei der Bank
anders als im Kontoauszug.
Spaeter genuegt ein kleiner Rueckgriff hinter den letzten Abruf; einen
doppelten Abruf faengt die Kennung des Umsatzes exakt ab.
Der Stempel gehoert an das Konto, nicht an die Verbindung: Sonst schoebe
ein Einzelabruf das Startdatum der uebrigen Konten mit vor, und deren
Umsaetze aus dem uebersprungenen Zeitraum kaemen nie an.
"""
heute = date.today()
if tage is not None:
# Uebergeht den letzten Abruf. Noetig, wenn etwas nicht angekommen ist:
# Sonst laege es fuer immer vor dem Fenster, obwohl die Bank es haette.
return heute - timedelta(days=tage)
if verknuepfung.last_sync_at:
# Sieben Tage statt drei: Banken buchen mitunter deutlich spaeter
# zurueck, und ein Umsatz, der einmal ausserhalb des Fensters lag,
# kaeme nie wieder. Doppelt holen kostet nichts – die Kennung des
# Umsatzes faengt das exakt ab.
return min(heute, verknuepfung.last_sync_at.date() - timedelta(days=RUECKGRIFF_TAGE))
frueheste = heute - timedelta(days=ERSTER_ABRUF_TAGE)
letzte = (
db.query(func.max(Transaction.date))
.filter(Transaction.owner_id == user.id, Transaction.account_id == konto.id)
.scalar()
)
if letzte is None:
return frueheste
# NIE in die Zukunft: Die Schnittstelle lehnt das rundweg ab
# ("date_from can not be in the future") – und zwar erst beim Umsatz-Abruf,
# nachdem die Saldi schon gelesen sind. Dann sieht es aus, als kaemen nur
# Saldi und keine Umsaetze. Liegt die neueste Buchung auf heute, waere der
# Tag danach morgen.
return min(heute, max(frueheste, letzte + timedelta(days=1)))
def _fremde_buchungen(db: Session, user: User, konto: Account, seit: date,
remote_uid: str) -> dict[tuple[str, int], list]:
"""Buchungen des Kontos, die NICHT aus DIESEM Bankkonto stammen.
Ein Abruf ueber einen laengeren Zeitraum greift in Monate zurueck, die
schon per CSV eingelesen sind. Der Duplikatschutz erkennt das nicht: Sein
Schluessel enthaelt den Empfaengertext, und der lautet bei der Bank anders
als im Auszug. Ohne diese Pruefung stuende die Zahlung zweimal da.
Ausgenommen sind nur Buchungen aus DEMSELBEN Bankkonto: Dort entscheidet
die Kennung der Bank, und die ist exakt – zwei Aufrundungen ueber 1.00
duerfen sich nicht gegenseitig verschlucken. Buchungen aus einem ANDEREN
Bankzugang zaehlen dagegen mit. Genau das passiert, wenn dieselbe Bank
zweimal verbunden ist (Enable Banking vergibt je Land eigene Kennungen):
Dieselbe Zahlung kaeme sonst ueber beide Zugaenge herein.
"""
aus_diesem_konto = {
txn_id
for (txn_id,) in db.query(InboxItem.transaction_id).filter(
InboxItem.owner_id == user.id,
InboxItem.source == InboxSource.bank,
InboxItem.transaction_id.isnot(None),
InboxItem.external_id.startswith(f"{remote_uid}:"),
)
}
je_schluessel: dict[tuple[str, int], list] = {}
for txn in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.account_id == konto.id,
Transaction.type != TxnType.transfer,
Transaction.date >= seit - timedelta(days=csv_import.INBOX_TAGE),
):
if txn.id in aus_diesem_konto:
continue
je_schluessel.setdefault((txn.type.value, txn.amount_cents), []).append(txn)
return je_schluessel
def _findet_fremde(fremde: dict, verbraucht: set[int], cents: int, tag: date):
"""Passende eigene Buchung – jede erklaert hoechstens EINEN Bank-Umsatz.
Ohne die Menge verschluckte eine Kaffeezahlung alle weiteren desselben
Betrags in der Umgebung.
"""
art = "expense" if cents < 0 else "income"
for txn in fremde.get((art, abs(cents)), []):
if txn.id in verbraucht:
continue
if abs((txn.date - tag).days) <= csv_import.INBOX_TAGE:
return txn
return None
def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
nur_link_id: int | None = None, tage: int | None = None) -> dict:
"""Umsätze der zugeordneten Konten dieser Verbindung in den Eingang holen.
Mit ``nur_link_id`` nur das eine Konto – samt seinem Saldo. Ein Abruf
bucht; wer ein Konto abruft, will das andere nicht angefasst haben.
"""
client = _client(db, user)
verknuepfungen = [v for v in _verknuepfungen(db, user, verbindung, nur_link_id)
if v.account_id is not None]
if not verknuepfungen:
# Ohne Zuordnung gibt es keine Umsaetze zu holen – die Saldi aber
# schon: Bei mehreren gleich aussehenden Pockets ist der Betrag das
# Einzige, woran man sie unterscheidet.
saldi = hole_salden(db, user, verbindung, client, nur_link_id)
return {"geholt": 0, "neu": 0, "saldi": saldi, "ohne_zuordnung": True}
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
geholt = neu = gebucht = vorgemerkt = doppelt = 0
ab: date | None = None
for verknuepfung in verknuepfungen:
konto = konten.get(verknuepfung.account_id)
if konto is None:
continue
# Je Konto eigen: Das eine ist bis gestern per CSV gepflegt, das andere
# ganz neu.
seit = _startdatum(db, user, konto, verknuepfung, tage)
ab = seit if ab is None else min(ab, seit)
# Je Konto EINE Menge verbrauchter Eingangs-Eintraege ...
verbraucht: set[int] = set()
# ... und eine fuer schon vorhandene, nicht von der Bank stammende
# Buchungen. Beide muessen ueber alle Zeilen des Kontos halten.
fremde = _fremde_buchungen(db, user, konto, seit,
verknuepfung.remote_uid)
fremde_verbraucht: set[int] = set()
for umsatz in client.umsaetze(verknuepfung.remote_uid, seit):
geholt += 1
zustand = _ist_gebucht(umsatz)
if zustand is None:
continue
if zustand is False:
# Nicht stillschweigend verwerfen: Sonst fehlt die Zahlung im
# Eingang und niemand weiss, warum.
vorgemerkt += 1
continue
# ALLE Kennungen, die der Umsatz mitbringt – nicht nur die erste.
# Liefert die Bank denselben Umsatz spaeter unter einer anderen
# (etwa weil aus vorgemerkt gebucht wurde), waeren es sonst zwei.
kennungen = [
f"{verknuepfung.remote_uid}:{wert}"[:255]
# Reihenfolge nach Bestaendigkeit: Die eigene Kennung der
# Schnittstelle bleibt gleich, wenn die Bank ihre Referenz beim
# Buchen neu vergibt. Gespeichert wird die erste – geprueft
# wird gegen alle, damit auch aeltere Eintraege noch treffen.
for feld in ("transaction_id", "internal_transaction_id",
"entry_reference", "uid")
if (wert := umsatz.get(feld))
]
if not kennungen:
continue # ohne Kennung kein Schutz vor Doppelabruf
schon = (
db.query(InboxItem.id)
.filter(InboxItem.owner_id == user.id,
InboxItem.source == InboxSource.bank,
InboxItem.external_id.in_(kennungen))
.first()
)
if schon is None:
# Und dann noch an den BUCHUNGEN selbst. Der Eingangs-Eintrag
# ist eine Zwischenstation: Er faellt weg, wenn ein Konto
# geloescht wird, und aeltere Backups kannten ihn nicht. Die
# Buchung bleibt – und traegt die Kennung seit 0027 selbst.
schon = eingang.gebuchte_mit_kennung(
db, user,
[eingang.quell_kennung(InboxSource.bank, k) for k in kennungen],
)
if schon is not None:
continue
kennung = kennungen[0]
cents = _betrag_cents(umsatz)
tag = _datum(umsatz)
if cents is None or cents == 0 or tag is None:
continue
payee, zweck = _text(umsatz, cents < 0)
# Durch dieselbe Erkennung wie CSV und Kurzbefehl: Schlagwortregeln
# schlagen die Kategorie vor, der Eingangs-Abgleich verhindert, dass
# eine bereits erfasste Zahlung ein zweites Mal auftaucht.
erkannt = csv_import.detect_rows(
db, user, konto,
[{"date": tag.isoformat(), "amount_cents": cents,
"payee": payee, "purpose": zweck}],
# Gegen andere BANK-Umsaetze nicht unscharf vergleichen: Dafuer
# gibt es die Kennung der Bank, und die ist exakt. Sonst
# verschluckt eine Aufrundung ueber 1.00 die naechste.
eingang_ohne=InboxSource.bank,
# Und ueber alle Zeilen dieses Kontos hinweg dieselbe Menge,
# denn hier wird je Zeile einzeln aufgerufen.
verbraucht=verbraucht,
)
zeile = erkannt[0] if erkannt else {}
if zeile.get("is_duplicate"):
continue
# Steht die Zahlung schon da, weil sie frueher per Datei oder von
# Hand erfasst wurde? Dann ist der Bank-Umsatz dieselbe Bewegung.
schon_da = _findet_fremde(fremde, fremde_verbraucht, cents, tag)
if schon_da is not None:
fremde_verbraucht.add(schon_da.id)
doppelt += 1
continue
waehrung = ((umsatz.get("transaction_amount") or {}).get("currency")
or konto.currency)
kurs, gegen_betrag, gegen_waehrung = _umrechnung(umsatz, waehrung)
eintrag = InboxItem(
owner_id=user.id,
source=InboxSource.bank,
status=InboxStatus.pending,
account_id=konto.id,
date=tag,
amount_cents=cents,
currency=waehrung,
payee=payee,
note=zweck,
category_id=zeile.get("category_id"),
external_id=kennung,
fx_rate=kurs,
counter_amount_cents=gegen_betrag,
counter_currency=gegen_waehrung,
)
db.add(eintrag)
db.flush()
neu += 1
# Sofort buchen. Ein angebundenes Konto ist die Quelle der Wahrheit:
# Was die Bank gebucht hat, IST gebucht – erst dadurch kann Certains
# Saldo gar nicht mehr abweichen. Eine Kategorie fehlt oft, die
# traegt man in Ruhe nach; ein fehlender Betrag waere schlimmer.
if eingang.buche(db, user, eintrag) is not None:
gebucht += 1
# Der Stempel gehoert an das Konto, das gerade abgerufen wurde.
verknuepfung.last_sync_at = datetime.now(timezone.utc)
verbindung.last_sync_at = datetime.now(timezone.utc)
verbindung.last_error = None
db.commit()
# Saldi ZULETZT. Vorher geholt waeren sie neuer als die Umsaetze: Bricht
# der Umsatz-Abruf ab, stuende ein aktueller Saldo neben altem Bestand,
# und die Anzeige meldete eine Abweichung, die keine ist.
saldi = hole_salden(db, user, verbindung, client, nur_link_id)
# Ein Uebertrag zwischen zwei angebundenen Konten kommt in EINEM Abruf an –
# beide Seiten. Ohne diesen Aufruf zaehlten sie als Ausgabe und Einnahme.
verknuepft = eingang.suche_uebertraege(db, user) if gebucht else 0
return {
"uebertraege": verknuepft,
"geholt": geholt, "neu": neu, "gebucht": gebucht, "vorgemerkt": vorgemerkt,
"doppelt": doppelt,
"saldi": saldi, "ab": ab.isoformat() if ab else None, "ohne_zuordnung": False,
}
def _datum(umsatz: dict) -> date | None:
for feld in ("booking_date", "value_date", "transaction_date"):
roh = umsatz.get(feld)
if not roh:
continue
try:
return date.fromisoformat(str(roh)[:10])
except ValueError:
continue
return None
def _mit_zeitzone(wert: datetime | None) -> datetime | None:
"""SQLite gibt Zeitstempel ohne Zeitzone zurueck, auch bei ``timezone=True``.
Ohne diese Ergaenzung scheitert jeder Vergleich mit einem TypeError – und
zwar weit weg von der Ursache.
"""
if wert is None or wert.tzinfo is not None:
return wert
return wert.replace(tzinfo=timezone.utc)
def faellig(verbindung: BankConnection, jetzt: datetime) -> bool:
"""Ist der automatische Abruf dieser Verbindung an der Reihe?
Bei ``poll_minutes == 0`` nie – nichts laeuft ungefragt los, denn ein Abruf
bucht.
"""
if not verbindung.poll_minutes:
return False
# Auch aeltere, zu knapp eingestellte Werte werden hier gebremst – sonst
# liefe eine bestehende Einstellung von 15 Minuten weiter gegen die Grenze.
takt = max(verbindung.poll_minutes, MIN_TAKT_MINUTEN)
zuletzt = _mit_zeitzone(verbindung.last_sync_at)
if zuletzt is None:
return True
return zuletzt + timedelta(minutes=takt) <= jetzt
def abruf_mit_fehlerbehandlung(db: Session, user: User,
verbindung: BankConnection) -> dict:
"""Wie ``hole_umsaetze``, schreibt einen Fehler aber in die Verbindung.
Und setzt dabei ``last_sync_at``: Sonst waere die Verbindung sofort wieder
faellig und liefe jede Minute in denselben Fehler.
"""
try:
return hole_umsaetze(db, user, verbindung)
except Exception as e: # BankFehler und alles Unerwartete
log.warning("Automatischer Abruf fuer %s fehlgeschlagen: %s",
verbindung.aspsp_name, e)
db.rollback()
verbindung.last_error = str(e)[:500]
verbindung.last_sync_at = datetime.now(timezone.utc)
db.commit()
return {"geholt": 0, "neu": 0, "gebucht": 0, "fehler": str(e)}
def laeuft_bald_ab(verbindung: BankConnection) -> bool:
"""Läuft die Zustimmung demnächst ab?
SQLite gibt Zeitstempel ohne Zeitzone zurück, auch wenn die Spalte als
``timezone=True`` deklariert ist. Ohne diese Ergänzung scheitert der
Vergleich mit einem TypeError – und zwar erst beim Anzeigen der
Verbindungsliste, also weit weg von der Ursache.
"""
if verbindung.valid_until is None:
return False
bis = verbindung.valid_until
if bis.tzinfo is None:
bis = bis.replace(tzinfo=timezone.utc)
return bis - datetime.now(timezone.utc) < timedelta(days=WARNUNG_TAGE)
+36
View File
@@ -0,0 +1,36 @@
"""Betraege einer Buchung berechnen.
Eigener Dienst, weil zwei Wege Buchungen anlegen: das Formular und der
Eingang, der einen Vorschlag als Uebertrag bucht. Zwei Kopien derselben
Umrechnung waeren genau die Stelle, an der Salden auseinanderlaufen.
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from ..models import Account, Transaction, TxnType
from . import currency
def fx(txn: Transaction) -> float | None:
return float(txn.fx_rate) if txn.fx_rate is not None else None
def setze_betraege(db: Session, txn: Transaction, von: Account,
nach: Account | None) -> None:
"""Setzt amount_eur_cents und (bei Uebertraegen) to_amount_cents neu."""
txn.amount_eur_cents = currency.compute_amount_eur_cents(
db, txn.amount_cents, von.currency, txn.date, fx(txn)
)
if txn.type == TxnType.transfer and nach is not None:
if txn.to_amount_cents is None:
if nach.currency == von.currency:
txn.to_amount_cents = txn.amount_cents
else:
conv = currency.convert(
db, txn.amount_cents, von.currency, nach.currency, txn.date, fx(txn)
)
txn.to_amount_cents = conv if conv is not None else txn.amount_cents
else:
txn.to_amount_cents = None
+271 -30
View File
@@ -13,7 +13,8 @@ from collections import defaultdict
from datetime import date, timedelta from datetime import date, timedelta
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from sqlalchemy.orm import Session from sqlalchemy import and_, or_
from sqlalchemy.orm import Session, aliased
from ..models import ( from ..models import (
Account, Account,
@@ -21,9 +22,11 @@ from ..models import (
BudgetPeriodLine, BudgetPeriodLine,
BudgetTemplateLine, BudgetTemplateLine,
BudgetTreatment, BudgetTreatment,
Category,
CategoryKind, CategoryKind,
SAVINGS_ACCOUNT_TYPES, SAVINGS_ACCOUNT_TYPES,
Transaction, Transaction,
TransferLink,
TxnType, TxnType,
User, User,
) )
@@ -55,15 +58,49 @@ def period_bounds(label: str, start_day: int) -> tuple[date, date]:
def period_of(d: date, start_day: int) -> str: def period_of(d: date, start_day: int) -> str:
"""Label des Budget-Monats, in den ein Datum fällt.""" """Label des Budget-Monats, in den ein Datum fällt.
Der Starttag wird auf den letzten Tag des Monats gekappt – einen 31.
Februar gibt es nicht. Ohne dieselbe Kappung wie in ``period_bounds`` fiele
der 28. Februar bei Starttag 31 in einen Monat, der ihn gar nicht enthält:
Die Ausgabe waere in keiner Auswertung aufgetaucht.
"""
if start_day <= 1: if start_day <= 1:
return f"{d.year:04d}-{d.month:02d}" return f"{d.year:04d}-{d.month:02d}"
if d.day >= start_day: if d >= _clamp_day(d.year, d.month, start_day):
nxt = date(d.year, d.month, 1) + relativedelta(months=1) nxt = date(d.year, d.month, 1) + relativedelta(months=1)
return f"{nxt.year:04d}-{nxt.month:02d}" return f"{nxt.year:04d}-{nxt.month:02d}"
return f"{d.year:04d}-{d.month:02d}" return f"{d.year:04d}-{d.month:02d}"
def zugeordnet(label: str, start: date, end: date, spalten=Transaction):
"""Filter: Buchungen, die zu diesem Budget-Monat gehoeren.
Normalfall ist das Datum. Traegt eine Buchung aber einen ausdruecklich
gesetzten Budget-Monat, gilt dieser – und ihr Datum zaehlt dann in KEINEM
Zeitraum mehr mit, sonst waere sie zweimal gezaehlt.
``spalten`` nimmt einen Alias entgegen. Noetig, sobald dieselbe Tabelle
zweimal in einer Abfrage steht – bei einem Uebertrag sind Abgang und Zugang
beide Buchungen, und gemeint ist nur der Abgang.
"""
return or_(
spalten.budget_period == label,
and_(spalten.budget_period.is_(None),
spalten.date >= start, spalten.date <= end),
)
def start_tag(db: Session, user: User) -> int:
"""Eingestellter Starttag des Budget-Monats, auf 1..31 begrenzt."""
from ..routers.settings import BUDGET_START_DAY_KEY, get_user_setting
try:
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
except (TypeError, ValueError):
return 1
def shift_label(label: str, months: int) -> str: def shift_label(label: str, months: int) -> str:
y, m = parse_label(label) y, m = parse_label(label)
d = date(y, m, 1) + relativedelta(months=months) d = date(y, m, 1) + relativedelta(months=months)
@@ -85,14 +122,41 @@ def _period(db: Session, user: User, label: str) -> BudgetPeriod | None:
) )
def effective_planned(db: Session, user: User, label: str) -> dict[int, int]: def hauptwaehrung(db: Session, user: User) -> str:
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage.""" """Waehrung, in der Soll-Betraege gefuehrt werden."""
from ..routers.settings import BASE_CURRENCY_KEY, get_user_setting
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
def soll_einer_zeile(zeile, aus_vertraegen: dict[int, int]) -> int:
"""Was eine Budget-Zeile als Soll bedeutet.
Bei "berechnet" ist die hingeschriebene Zahl nicht der Soll-Betrag, sondern
nur der Zuschlag – dazu kommt, was die Vertraege dieser Kategorie kosten.
"""
if zeile.modus == "berechnet":
return aus_vertraegen.get(zeile.category_id, 0) + zeile.zuschlag_cents
return zeile.planned_cents
def _zeilen(db: Session, user: User, label: str):
"""Die wirksamen Budget-Zeilen: Monats-Override falls vorhanden, sonst Vorlage."""
period = _period(db, user, label) period = _period(db, user, label)
if period is not None: if period is not None:
return {ln.category_id: ln.planned_cents for ln in period.lines} return period.lines
return db.query(BudgetTemplateLine).filter(
BudgetTemplateLine.owner_id == user.id).all()
def effective_planned(db: Session, user: User, label: str) -> dict[int, int]:
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage."""
from . import abos
aus_vertraegen = abos.monatlich_je_kategorie(db, user, hauptwaehrung(db, user))
return { return {
tl.category_id: tl.planned_cents ln.category_id: soll_einer_zeile(ln, aus_vertraegen)
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id) for ln in _zeilen(db, user, label)
} }
@@ -108,23 +172,43 @@ def ensure_customized(db: Session, user: User, label: str) -> BudgetPeriod:
db.add(period) db.add(period)
db.flush() db.flush()
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id): for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
period.lines.append( period.lines.append(BudgetPeriodLine(
BudgetPeriodLine(category_id=tl.category_id, planned_cents=tl.planned_cents) category_id=tl.category_id, planned_cents=tl.planned_cents,
) # Ohne diese beiden fiele ein angepasster Monat auf die Zahl
# zurueck, die zufaellig zuletzt in ``planned_cents`` stand.
modus=tl.modus, zuschlag_cents=tl.zuschlag_cents,
))
db.flush() db.flush()
return period return period
def set_month_category(db: Session, user: User, label: str, category_id: int, planned_cents: int) -> None: def set_month_category(db: Session, user: User, label: str, category_id: int,
planned_cents: int | None = None, modus: str | None = None,
zuschlag_cents: int | None = None) -> None:
period = ensure_customized(db, user, label) period = ensure_customized(db, user, label)
line = next((ln for ln in period.lines if ln.category_id == category_id), None) line = next((ln for ln in period.lines if ln.category_id == category_id), None)
if line is None: if line is None:
period.lines.append(BudgetPeriodLine(category_id=category_id, planned_cents=planned_cents)) line = BudgetPeriodLine(category_id=category_id, planned_cents=0)
else: period.lines.append(line)
line.planned_cents = planned_cents uebernimm(line, planned_cents, modus, zuschlag_cents)
db.commit() db.commit()
def uebernimm(zeile, planned_cents: int | None, modus: str | None,
zuschlag_cents: int | None) -> None:
"""Setzt an einer Budget-Zeile, was mitgegeben wurde – und nur das.
Getrennt gehalten, weil Vorlage und Monat dieselbe Regel brauchen und zwei
Kopien davon genau die Stelle waeren, an der sie auseinanderlaufen.
"""
if planned_cents is not None:
zeile.planned_cents = max(0, planned_cents)
if zuschlag_cents is not None:
zeile.zuschlag_cents = max(0, zuschlag_cents)
if modus is not None:
zeile.modus = modus
def reset_month(db: Session, user: User, label: str) -> None: def reset_month(db: Session, user: User, label: str) -> None:
period = _period(db, user, label) period = _period(db, user, label)
if period is not None: if period is not None:
@@ -132,6 +216,56 @@ def reset_month(db: Session, user: User, label: str) -> None:
db.commit() db.commit()
def copy_month_to_template(db: Session, user: User, label: str) -> None:
"""Setzt die Standard-Vorlage auf die Soll-Werte dieses Monats
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt.
Übernommen wird die **Betriebsart**, nicht die gerade herauskommende Zahl:
Eine berechnete Zeile soll auch in der Vorlage berechnet bleiben. Vorher
fror dieser Weg den Monatswert als festen Betrag ein – der Bezug zu den
Verträgen war danach still weg, und niemand sah, wo er geblieben war.
"""
zeilen = list(_zeilen(db, user, label))
db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).delete()
for zeile in zeilen:
# Eine feste Null ist nichts Geplantes und gehört nicht in die Vorlage.
# Eine berechnete Zeile dagegen schon: Ihre Zahl steht in den
# Verträgen, nicht in planned_cents.
if zeile.modus != "berechnet" and zeile.planned_cents <= 0:
continue
db.add(BudgetTemplateLine(
owner_id=user.id, category_id=zeile.category_id,
planned_cents=zeile.planned_cents, modus=zeile.modus,
zuschlag_cents=zeile.zuschlag_cents,
))
db.commit()
def copy_template_to_month(db: Session, user: User, label: str) -> BudgetPeriod:
"""Überschreibt den Monat mit der aktuellen Vorlage (auch wenn er schon
angepasst war): vorhandene Monatszeilen weg, Vorlage frisch übernehmen."""
period = _period(db, user, label)
if period is None:
period = BudgetPeriod(owner_id=user.id, label=label, customized=True)
db.add(period)
db.flush()
else:
for ln in list(period.lines):
db.delete(ln)
db.flush()
period.customized = True
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
period.lines.append(BudgetPeriodLine(
category_id=tl.category_id, planned_cents=tl.planned_cents,
# Wie in ``ensure_customized``: Ohne diese beiden fiele eine
# berechnete Zeile auf „fest" zurueck, mit der Zahl, die zufaellig
# zuletzt in planned_cents stand.
modus=tl.modus, zuschlag_cents=tl.zuschlag_cents,
))
db.commit()
return period
# --- Ist-Werte ------------------------------------------------------------- # --- Ist-Werte -------------------------------------------------------------
@@ -142,33 +276,107 @@ def _to_base(db: Session, eur_cents: int, base: str, on: date) -> int:
return conv if conv is not None else eur_cents return conv if conv is not None else eur_cents
def actual_expense_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int, int]: def _neutrale_kategorien(db: Session, user: User) -> set[int]:
"""Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers).""" """Kategorien, die nicht in Auswertungen zählen (z. B. „Kontoübertrag").
result: dict[int, int] = defaultdict(int)
Eine Umbuchung zwischen eigenen Konten ist keine Ausgabe – das Geld liegt nur
woanders. Auf den Kontostand wirkt sie weiterhin, nur Budget und Statistik
lassen sie außen vor."""
return {
cid
for (cid,) in db.query(Category.id).filter(
Category.owner_id == user.id, Category.excluded_from_stats.is_(True)
)
}
def _spar_uebertraege(db: Session, user: User, label: str, start: date,
end: date) -> list[tuple[int, Transaction]]:
"""Verknuepfte Uebertraege, deren ZIELKONTO als Sparen gilt.
Rueckgabe je Treffer: (Kategorie, Abgangs-Buchung).
Der Bankabruf legt einen Uebertrag NICHT als eine Buchung mit Zielkonto an,
sondern als zwei verknuepfte Buchungen – Abgang hier, Zugang dort. Beide
ueberspringt die Auswertung sonst, und das ist bei einem Uebertrag zwischen
Girokonto und Tagesgeld auch richtig: Das Geld ist nur woanders.
Beim Sparen ist es das eben nicht. Wer 200 auf sein Sparkonto legt, hat
diese 200 in dem Monat nicht mehr zum Ausgeben – genau das soll ein Budget
abbilden. Traegt das Zielkonto eine Spar-Kategorie, zaehlt der Abgang
deshalb wie eine Ausgabe gegen sie.
Gezaehlt wird immer der ABGANG: Sein Betrag steht in der Waehrung des
Kontos, von dem das Geld wirklich verschwindet.
"""
abgang = aliased(Transaction)
zugang = aliased(Transaction)
treffer: list[tuple[int, Transaction]] = []
for buchung, konto_kategorie in (
db.query(abgang, Account.savings_category_id)
.join(TransferLink, TransferLink.from_txn_id == abgang.id)
.join(zugang, TransferLink.to_txn_id == zugang.id)
.join(Account, Account.id == zugang.account_id)
.filter(TransferLink.owner_id == user.id,
Account.savings_category_id.isnot(None),
zugeordnet(label, start, end, abgang))
):
# Dieselben zwei Felder wie beim Uebertrag am Stueck – wer eine einzelne
# Bewegung anders behandelt haben will, sagt es an der Buchung, nicht am
# Konto: "neutral" nimmt sie ganz heraus, eine eigene Budget-Kategorie
# schlaegt die des Kontos.
if buchung.budget_treatment == BudgetTreatment.neutral:
continue
treffer.append((buchung.budget_category_id or konto_kategorie, buchung))
return treffer
def expense_rows(db: Session, user: User, label: str, start: date, end: date,
base: str) -> list[tuple[int, Transaction, int]]:
"""Die einzelnen Buchungen hinter den Ist-Ausgaben: (Kategorie, Buchung, Betrag).
Die Summe darueber IST der Ist-Wert – beides kommt aus dieser einen Stelle,
damit eine Aufstellung nie etwas anderes ergeben kann als die Zahl, die sie
erklaeren soll.
"""
from .uebertraege import verknuepfte_ids
neutral = _neutrale_kategorien(db, user)
# Verknüpfte Buchungen sind zwei Seiten derselben Bewegung zwischen eigenen
# Konten – Geld wurde verschoben, nicht ausgegeben. Übersprungen, nicht
# umkategorisiert: Löst man die Verknüpfung, zählt die Buchung mit ihrer
# alten Kategorie sofort wieder mit.
verknuepft = verknuepfte_ids(db, user)
zeilen: list[tuple[int, Transaction, int]] = []
expenses = ( expenses = (
db.query(Transaction) db.query(Transaction)
.filter( .filter(
Transaction.owner_id == user.id, Transaction.owner_id == user.id,
Transaction.type == TxnType.expense, Transaction.type == TxnType.expense,
Transaction.date >= start, zugeordnet(label, start, end),
Transaction.date <= end,
Transaction.category_id.isnot(None), Transaction.category_id.isnot(None),
) )
.all() .all()
) )
for t in expenses: for t in expenses:
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date) if t.category_id in neutral or t.id in verknuepft:
continue
zeilen.append((t.category_id, t, _to_base(db, t.amount_eur_cents, base, t.date)))
# Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als # Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als
# budgetierte Ausgabe gegen budget_category_id. # budgetierte Ausgabe. Gegen welche Kategorie, sagt die Buchung selbst –
# und wenn sie schweigt, das Zielkonto.
#
# Eine Kategorie mit "Nicht in Auswertungen einrechnen" bleibt auch hier
# aussen vor. Der Haken an der Kategorie ist eine ausdrueckliche Ansage des
# Nutzers; ein Sparziel am Konto darf sie nicht stillschweigend aushebeln.
transfers = ( transfers = (
db.query(Transaction) db.query(Transaction)
.filter( .filter(
Transaction.owner_id == user.id, Transaction.owner_id == user.id,
Transaction.type == TxnType.transfer, Transaction.type == TxnType.transfer,
Transaction.date >= start, zugeordnet(label, start, end),
Transaction.date <= end,
Transaction.budget_category_id.isnot(None),
) )
.all() .all()
) )
@@ -176,23 +384,56 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date,
if t.budget_treatment == BudgetTreatment.neutral: if t.budget_treatment == BudgetTreatment.neutral:
continue continue
to_acc = db.get(Account, t.to_account_id) if t.to_account_id else None to_acc = db.get(Account, t.to_account_id) if t.to_account_id else None
if to_acc is not None and to_acc.type in SAVINGS_ACCOUNT_TYPES: if to_acc is None:
result[t.budget_category_id] += _to_base(db, t.amount_eur_cents, base, t.date) continue
# Das Zielkonto sagt es ausdruecklich – oder sein Typ sagt es als
# Vorgabe. Ohne beides ist ein Uebertrag kein Sparen, sondern bloss
# verschobenes Geld.
if to_acc.savings_category_id is None and to_acc.type not in SAVINGS_ACCOUNT_TYPES:
continue
kategorie = t.budget_category_id or to_acc.savings_category_id
if kategorie is None or kategorie in neutral:
continue
zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date)))
# Und dasselbe fuer die andere Form des Uebertrags: zwei verknuepfte
# Buchungen, wie sie der Bankabruf liefert. Ohne diesen Block bliebe Sparen
# ueber ein angebundenes Konto fuer das Budget unsichtbar.
for kategorie, t in _spar_uebertraege(db, user, label, start, end):
if kategorie is None or kategorie in neutral:
continue
zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date)))
return zeilen
def actual_expense_by_category(db: Session, user: User, label: str, start: date, end: date,
base: str) -> dict[int, int]:
"""Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers)."""
result: dict[int, int] = defaultdict(int)
for cat_id, _txn, cents in expense_rows(db, user, label, start, end, base):
result[cat_id] += cents
return result return result
def income_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int | None, int]: def income_by_category(db: Session, user: User, label: str, start: date, end: date,
base: str) -> dict[int | None, int]:
from .uebertraege import verknuepfte_ids
result: dict[int | None, int] = defaultdict(int) result: dict[int | None, int] = defaultdict(int)
neutral = _neutrale_kategorien(db, user)
# Die Gegenseite eines Übertrags ist keine Einnahme (siehe oben).
verknuepft = verknuepfte_ids(db, user)
incomes = ( incomes = (
db.query(Transaction) db.query(Transaction)
.filter( .filter(
Transaction.owner_id == user.id, Transaction.owner_id == user.id,
Transaction.type == TxnType.income, Transaction.type == TxnType.income,
Transaction.date >= start, zugeordnet(label, start, end),
Transaction.date <= end,
) )
.all() .all()
) )
for t in incomes: for t in incomes:
if t.category_id in neutral or t.id in verknuepft:
continue
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date) result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
return result return result
+711 -59
View File
@@ -6,6 +6,8 @@ import csv
import hashlib import hashlib
import io import io
import json import json
import logging
from collections import Counter
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
@@ -14,12 +16,14 @@ from sqlalchemy.orm import Session
from ..models import ( from ..models import (
Account, Account,
AccountIdentifier, AccountIdentifier,
InboxItem,
InboxSource,
InboxStatus,
AmountMode, AmountMode,
BudgetTreatment, BudgetTreatment,
CategorizationRule, CategorizationRule,
Category, Category,
CategoryKind, CategoryKind,
IdentifierKind,
ImportBatch, ImportBatch,
ImportStatus, ImportStatus,
ImportTemplate, ImportTemplate,
@@ -32,12 +36,16 @@ from ..models import (
) )
from . import currency from . import currency
log = logging.getLogger(__name__)
# Vorgefertigte Bank-Vorlagen (aus echten Export-Beispielen). Erscheinen im # Vorgefertigte Bank-Vorlagen (aus echten Export-Beispielen). Erscheinen im
# Import als auswählbare Presets und füllen die Format-/Spalten-Einstellungen. # Import als auswählbare Presets und füllen die Format-/Spalten-Einstellungen.
PRESETS: list[dict] = [ PRESETS: list[dict] = [
{ {
"name": "Raiffeisen (CH)", "name": "Raiffeisen (CH)",
# Kopfzeilen, an denen sich die Bank sicher erkennen laesst.
"signature": ("iban", "booked at", "credit/debit amount", "balance"),
"delimiter": ";", "delimiter": ";",
"decimal_sep": ".", "decimal_sep": ".",
"date_format": "%Y-%m-%d %H:%M:%S.%f", "date_format": "%Y-%m-%d %H:%M:%S.%f",
@@ -46,11 +54,31 @@ PRESETS: list[dict] = [
"skip_rows": 0, "skip_rows": 0,
"amount_mode": "signed", "amount_mode": "signed",
# Spalten: 0=eigene IBAN, 1=Datum, 2=Text, 3=Betrag, 4=Saldo, 5=Valuta. # Spalten: 0=eigene IBAN, 1=Datum, 2=Text, 3=Betrag, 4=Saldo, 5=Valuta.
"column_map": {"date": 1, "amount": 3, "payee": 2, "column_map": {"date": 1, "amount": 3, "payee": 2, "balance": 4,
"purpose": None, "counterparty_iban": None}, "purpose": None, "counterparty_iban": None},
}, },
{
"name": "N26",
"signature": ("booking date", "partner name", "payment reference"),
"delimiter": ",",
"decimal_sep": ".",
"date_format": "%Y-%m-%d",
"encoding": "utf-8",
"has_header": True,
"skip_rows": 0,
"amount_mode": "signed",
# Spalten: 0=Buchungsdatum, 1=Valuta, 2=Partner, 3=Partner-IBAN, 4=Typ,
# 5=Verwendungszweck, 6=Kontoname, 7=Betrag (EUR), 8=Originalbetrag,
# 9=Originalwährung, 10=Kurs. Eine Saldo-Spalte gibt es hier nicht,
# deshalb ist beim Import kein Saldo-Abgleich möglich.
"column_map": {"date": 0, "amount": 7, "payee": 2, "purpose": 5,
"counterparty_iban": 3},
},
{ {
"name": "Revolut", "name": "Revolut",
"signature": ("produkt", "beschreibung", "betrag", "kontostand"),
# Der englische Export derselben Bank.
"signature_alt": ("product", "description", "amount", "balance"),
"delimiter": ",", "delimiter": ",",
"decimal_sep": ".", "decimal_sep": ".",
"date_format": "%Y-%m-%d %H:%M:%S", "date_format": "%Y-%m-%d %H:%M:%S",
@@ -60,12 +88,301 @@ PRESETS: list[dict] = [
"amount_mode": "signed", "amount_mode": "signed",
# Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung, # Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung,
# 5=Betrag, 6=Gebühr, 7=Währung, 8=Status, 9=Saldo. # 5=Betrag, 6=Gebühr, 7=Währung, 8=Status, 9=Saldo.
"column_map": {"date": 3, "amount": 5, "payee": 4, "column_map": {"date": 3, "amount": 5, "payee": 4, "fee": 6,
"currency": 7, "state": 8, "balance": 9,
"purpose": None, "counterparty_iban": None}, "purpose": None, "counterparty_iban": None},
}, },
] ]
# --- Format erkennen -------------------------------------------------------
# Reihenfolge zaehlt: Das erste Format, das die Beispielwerte vollstaendig
# liest, gewinnt. Mit Uhrzeit zuerst, weil ein Datumsformat OHNE Uhrzeit auch
# auf Zeilen MIT Uhrzeit passt (parse_date ist nachsichtig) – umgekehrt nicht.
DATUMSFORMATE = (
"%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d",
"%d.%m.%Y %H:%M:%S", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y",
)
TRENNZEICHEN = (";", ",", "\t", "|")
# Kopfzeilen-Wörter, die auf ein Feld hindeuten. Der Inhalt entscheidet, aber
# bei mehreren gleich guten Spalten gibt die Beschriftung den Ausschlag.
KOPF_HINWEISE = {
"date": ("buchung", "booked", "datum", "date", "valuta"),
"amount": ("betrag", "amount", "umsatz", "credit", "debit"),
"balance": ("saldo", "balance", "kontostand"),
# „haendler"/„merchant" kommen von Kreditkarten – dort heisst die Spalte
# praktisch nie „Empfaenger". Beide Schreibweisen, weil normalize_text nur
# kleinschreibt und Umlaute stehen laesst.
"payee": ("empfaenger", "empfänger", "partner", "beschreibung", "description",
"text", "name", "auftraggeber", "haendler", "händler", "merchant"),
"purpose": ("zweck", "reference", "verwendung", "mitteilung", "referenz"),
"counterparty_iban": ("iban",),
}
def _spaltenwerte(zeilen: list[list[str]], i: int) -> list[str]:
return [r[i].strip() for r in zeilen if i < len(r) and r[i].strip()]
def _datumsanteil(werte: list[str], fmt: str) -> float:
"""Streng prüfen, nicht mit ``parse_date``.
``parse_date`` ist absichtlich nachsichtig: Es liest „01.02.2026“ auch mit
dem Format „%d.%m.%Y %H:%M:%S“. Beim Erkennen ist das schädlich – dann
gewinnt ein Format mit Uhrzeit gegen das richtige ohne, und in den
Einstellungen steht hinterher etwas Falsches.
"""
if not werte:
return 0.0
treffer = 0
for w in werte:
try:
datetime.strptime(w.strip(), fmt)
treffer += 1
except (ValueError, TypeError):
pass
return treffer / len(werte)
def _geldanteil(werte: list[str], sep: str) -> float:
"""Anteil der Werte, die wie ein Geldbetrag aussehen.
Reine Ganzzahlen zaehlen nur halb: Referenznummern und Kurse sehen sonst
aus wie Betraege, und eine Spalte „Exchange Rate" als Betrag zu nehmen
waere ein stiller, teurer Fehler.
"""
if not werte:
return 0.0
punkte = 0.0
for w in werte:
if parse_amount(w, sep) is None:
continue
punkte += 1.0 if (sep in w or "-" in w) else 0.5
return punkte / len(werte)
def _kopf_passt(kopf: str, feld: str) -> bool:
k = normalize_text(kopf)
return any(h in k for h in KOPF_HINWEISE.get(feld, ()))
def erkenne_format(data: bytes, encoding: str = "utf-8") -> dict:
"""Aus einer CSV-Datei ableiten, wie sie zu lesen ist.
Zuerst gegen die Bank-Vorlagen: Stimmt die Kopfzeile, ist alles bekannt.
Sonst inhaltsbasiert – welche Spalte enthaelt Daten, welche Betraege, welche
den laengsten Text. Das erspart das Ausfuellen des Formulars, das bisher bei
jedem Import noetig war.
Liefert die Einstellung, eine Begruendung je Feld und wie sicher der Fund
ist. Unsicheres wird gemeldet, nicht verschwiegen.
"""
text = decode_bytes(data, encoding)
erste = next((z for z in text.splitlines() if z.strip()), "")
# Trennzeichen: das haeufigste in der Kopfzeile, das ueberhaupt trennt.
trenner = max(TRENNZEICHEN, key=lambda t: erste.count(t))
if erste.count(trenner) == 0:
trenner = ";"
rohzeilen = [r for r in csv.reader(io.StringIO(text), delimiter=trenner)
if any((c or "").strip() for c in r)]
if not rohzeilen:
return {"ok": False, "grund": "Die Datei enthält keine lesbaren Zeilen."}
kopf = [c.strip() for c in rohzeilen[0]]
zeilen = rohzeilen[1:41] # eine Handvoll reicht zum Erkennen
if not zeilen:
return {"ok": False, "grund": "Die Datei enthält nur eine Kopfzeile."}
kopf_klein = [normalize_text(c) for c in kopf]
for p in PRESETS:
for schluessel in ("signature", "signature_alt"):
sig = p.get(schluessel)
if sig and all(any(s in k for k in kopf_klein) for s in sig):
return {
"ok": True,
"preset": p["name"],
"sicherheit": "hoch",
"begruendung": f"Kopfzeile passt zur Vorlage „{p['name']}“.",
"config": {k: v for k, v in p.items()
if k not in ("name", "signature", "signature_alt")},
"header": kopf,
}
# Zuerst die Datumsspalte: Deutsche Datumsangaben („15.03.2026") stecken
# voller Punkte und würden sonst die Erkennung des Dezimaltrenners kippen.
datum_i, datum_fmt, bestes = None, "%d.%m.%Y", 0.0
for i in range(len(kopf)):
werte = _spaltenwerte(zeilen, i)
for fmt in DATUMSFORMATE:
anteil = _datumsanteil(werte, fmt)
if anteil > bestes or (anteil == bestes and anteil > 0.8 and _kopf_passt(kopf[i], "date")):
datum_i, datum_fmt, bestes = i, fmt, anteil
if datum_i is None or bestes < 0.6:
return {"ok": False, "grund": "Keine Spalte mit Datumswerten gefunden.",
"header": kopf}
# ALLE Datumsspalten heraushalten, nicht nur die gewählte: Viele Auszüge
# führen Buchungs- UND Valutadatum, und beide zählen sonst als Punkte mit.
datumsspalten = {
i for i in range(len(kopf))
if any(_datumsanteil(_spaltenwerte(zeilen, i), f) > 0.8 for f in DATUMSFORMATE)
}
ohne_datum = " ".join(
" ".join(c for j, c in enumerate(r) if j not in datumsspalten) for r in zeilen
)
dezimal = "," if ohne_datum.count(",") > ohne_datum.count(".") and trenner != "," else "."
geld = sorted(
((i, _geldanteil(_spaltenwerte(zeilen, i), dezimal)) for i in range(len(kopf))
if i != datum_i),
key=lambda x: (-x[1], not _kopf_passt(kopf[x[0]], "amount")),
)
geldspalten = [i for i, anteil in geld if anteil > 0.6]
if not geldspalten:
return {"ok": False, "grund": "Keine Spalte mit Beträgen gefunden.", "header": kopf}
betrag_i = next((i for i in geldspalten if _kopf_passt(kopf[i], "amount")), geldspalten[0])
saldo_i = next((i for i in geldspalten
if i != betrag_i and _kopf_passt(kopf[i], "balance")), None)
# Kontonummern sind kein Empfaenger. Viele Auszuege – Kreditkarten fast
# immer – fuehren die eigene IBAN in jeder Zeile; sie ist laenger als jeder
# Haendlername und gewaenne die Spaltenwahl allein ueber die Textlaenge.
# Herausgekommen waere eine Liste, in der bei jeder Buchung dasselbe steht.
# Zwei Bedingungen, beide noetig:
#
# 1. In fast jeder Zeile steht DASSELBE. Was sich nicht von Buchung zu
# Buchung aendert, sagt ueber die einzelne Buchung nichts aus.
# 2. Es sieht aus wie eine Kontonummer: zusammenhaengend, alphanumerisch,
# mindestens 15 Zeichen, mit Ziffern.
#
# Die erste Bedingung allein waere zu scharf (ein kurzer Auszug kann
# denselben Haendler mehrfach enthalten), die zweite allein auch: Ein
# Verwendungszweck wie „Rechnung 12345 vom 01.02." ergaebe ohne Trennzeichen
# ebenfalls eine lange Zeichenkette.
def _ist_kontonummer(i: int) -> bool:
werte = _spaltenwerte(zeilen, i)
if not werte:
return False
haeufigste = max(Counter(werte).values())
if haeufigste < 0.8 * len(werte):
return False
wert = werte[0].replace(" ", "")
return len(wert) >= 15 and wert.isalnum() and any(c.isdigit() for c in wert)
text_spalten = sorted(
((i, sum(len(w) for w in _spaltenwerte(zeilen, i)) / max(1, len(zeilen)))
for i in range(len(kopf))
if i not in (datum_i, betrag_i, saldo_i) and not _ist_kontonummer(i)),
key=lambda x: -x[1],
)
lange = [i for i, laenge in text_spalten if laenge >= 3]
payee_i = next((i for i in lange if _kopf_passt(kopf[i], "payee")),
lange[0] if lange else None)
purpose_i = next((i for i in lange
if i != payee_i and _kopf_passt(kopf[i], "purpose")), None)
iban_i = next((i for i in range(len(kopf))
if i not in (datum_i, betrag_i) and _kopf_passt(kopf[i], "counterparty_iban")
and any(len(w) > 14 for w in _spaltenwerte(zeilen, i))), None)
spalten = {"date": datum_i, "amount": betrag_i, "payee": payee_i,
"purpose": purpose_i, "balance": saldo_i, "counterparty_iban": iban_i}
begruendung = " · ".join(
f"{feld}: Spalte {i} „{kopf[i]}“" for feld, i in spalten.items() if i is not None
)
return {
"ok": True,
"preset": None,
"sicherheit": "hoch" if bestes > 0.9 and payee_i is not None else "mittel",
"begruendung": begruendung,
"config": {
"delimiter": trenner, "decimal_sep": dezimal, "date_format": datum_fmt,
"encoding": encoding, "has_header": True, "skip_rows": 0,
"amount_mode": "signed", "column_map": spalten,
},
"header": kopf,
}
def erkenne_konto(db: Session, user: User, data: bytes, erkannt: dict) -> dict:
"""Zu welchem Konto gehört diese Datei?
Damit reicht EIN Kurzbefehl fürs Telefon statt einer je Konto. Zwei
Anhaltspunkte, in dieser Reihenfolge:
1. Eine Spalte mit der EIGENEN IBAN (Raiffeisen führt sie in Spalte 0).
Trifft sie einen Erkennungs-Text eines Kontos, ist die Sache eindeutig.
2. Die Währung der Zeilen zusammen mit der erkannten Bank. Revolut liefert
je Währung eine eigene Datei; welches Konto gemeint ist, verrät der
Import-Verlauf – dorthin ging zuletzt eine Datei derselben Bank.
Bleibt es mehrdeutig, wird NICHT geraten: Ein Auszug im falschen Konto
verfälscht zwei Salden auf einmal.
"""
if not erkannt.get("ok"):
return {"account_id": None, "grund": erkannt.get("grund", "Format unbekannt")}
config = erkannt["config"]
text = decode_bytes(data, config.get("encoding") or "utf-8")
zeilen = [r for r in csv.reader(io.StringIO(text), delimiter=config["delimiter"])
if any((c or "").strip() for c in r)]
if len(zeilen) < 2:
return {"account_id": None, "grund": "Zu wenige Zeilen"}
proben = zeilen[1:31]
konten = db.query(Account).filter(Account.owner_id == user.id,
Account.is_archived.is_(False)).all()
# 1. Eigene IBAN in einer Spalte.
idents: dict[str, int] = {}
for acc_id, wert in db.query(AccountIdentifier.account_id, AccountIdentifier.value_norm).join(
Account, Account.id == AccountIdentifier.account_id
).filter(Account.owner_id == user.id):
if wert:
idents[normalize_iban(wert)] = acc_id
breite = max(len(r) for r in proben)
for i in range(breite):
werte = {normalize_iban(r[i]) for r in proben if i < len(r) and r[i].strip()}
werte = {w for w in werte if len(w) >= 15}
if len(werte) == 1:
treffer = idents.get(next(iter(werte)))
if treffer:
return {"account_id": treffer, "grund": "eigene IBAN in der Datei",
"sicher": True}
# 2. Währung plus zuletzt benutztes Konto derselben Bank.
waehrung_i = config["column_map"].get("currency")
waehrungen = set()
if waehrung_i is not None:
waehrungen = {r[waehrung_i].strip().upper()
for r in proben if waehrung_i < len(r) and r[waehrung_i].strip()}
if len(waehrungen) == 1:
w = next(iter(waehrungen))
passend = [a for a in konten if a.currency.upper() == w]
if len(passend) == 1:
return {"account_id": passend[0].id, "grund": f"einziges Konto in {w}", "sicher": True}
if passend and erkannt.get("preset"):
frueher = {
b.account_id
for b in db.query(ImportBatch).filter(
ImportBatch.owner_id == user.id,
ImportBatch.preset == erkannt["preset"],
ImportBatch.status == ImportStatus.committed,
)
}
treffer = [a for a in passend if a.id in frueher]
if len(treffer) == 1:
return {"account_id": treffer[0].id,
"grund": f"{w} und zuletzt für „{erkannt['preset']}“ benutzt",
"sicher": True}
return {"account_id": None,
"grund": "Das Konto lässt sich aus der Datei nicht sicher ableiten"}
# --- Kleine Parser-Helfer -------------------------------------------------- # --- Kleine Parser-Helfer --------------------------------------------------
@@ -97,13 +414,34 @@ def parse_amount(raw: str | None, decimal_sep: str) -> int | None:
def parse_date(raw: str | None, fmt: str) -> date | None: def parse_date(raw: str | None, fmt: str) -> date | None:
"""Datumstext -> Datum. Nachsichtig gegenüber einer Uhrzeit, die im Format
fehlt oder umgekehrt: Banken exportieren dieselbe Spalte mal als „2026-01-06",
mal als „2026-01-06 23:11:14" (auch mit Sekundenbruchteilen). Sonst müsste man
im Dropdown für jede Variante ein eigenes Format anbieten."""
s = str(raw or "").strip() s = str(raw or "").strip()
if not s: if not s:
return None return None
try: try:
return datetime.strptime(s, fmt).date() return datetime.strptime(s, fmt).date()
except ValueError: except ValueError:
return None pass
hat_zeit_im_format = any(x in fmt for x in ("%H", "%M", "%S", "%f"))
# Wert bringt eine Uhrzeit mit, das Format kennt keine -> nur den Datumsteil.
if not hat_zeit_im_format and (" " in s or "T" in s):
kopf = s.replace("T", " ").split(" ", 1)[0]
try:
return datetime.strptime(kopf, fmt).date()
except ValueError:
pass
# Format erwartet eine Uhrzeit, der Wert hat keine -> Format kürzen.
if hat_zeit_im_format:
nur_datum = fmt.replace("T", " ").split(" ", 1)[0]
try:
return datetime.strptime(s.replace("T", " ").split(" ", 1)[0], nur_datum).date()
except ValueError:
pass
return None
def decode_bytes(data: bytes, encoding: str) -> str: def decode_bytes(data: bytes, encoding: str) -> str:
@@ -121,11 +459,35 @@ def read_rows(data: bytes, template: ImportTemplate) -> list[list[str]]:
reader = csv.reader(io.StringIO(text), delimiter=delim) reader = csv.reader(io.StringIO(text), delimiter=delim)
rows = [r for r in reader if any((c or "").strip() for c in r)] rows = [r for r in reader if any((c or "").strip() for c in r)]
start = template.skip_rows + (1 if template.has_header else 0) start = template.skip_rows + (1 if template.has_header else 0)
return rows[start:] rows = rows[start:]
# Zeilenfilter: Revolut legt alle Pockets in eine Datei, jedes mit eigenem
# Saldo-Verlauf. Ohne Filter landet alles auf demselben Konto.
spalte = template.filter_column
wert = (template.filter_value or "").strip().lower()
if spalte is not None and wert:
rows = [
r for r in rows
if spalte < len(r) and (r[spalte] or "").strip().lower() == wert
]
return rows
# Eine Spalte taugt als Zeilenfilter, wenn sie die Datei in wenige, klar
# benannte Gruppen teilt – etwa „Produkt" bei Revolut (Giro, Erspartes,
# Anlagekonto). Mehr als eine Handvoll Werte ist keine Gruppe mehr, sondern
# ein Datenfeld wie Empfänger oder Betrag.
MAX_FILTERWERTE = 12
def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict: def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict:
"""Rohe erste Zeilen (zum Spalten-Zuordnen im Template-Editor).""" """Rohe erste Zeilen (zum Spalten-Zuordnen im Template-Editor).
Liefert zusätzlich je Spalte die wählbaren Filterwerte mit Zeilenzahl.
Revolut legt alle Produkte in EINE Datei, jedes mit eigener Kontostand-
Spalte; ohne Filter zählen die Bewegungen doppelt und der Saldo-Abgleich
kann gar nicht aufgehen. Die Werte hier zu kennen erspart das Raten.
"""
text = decode_bytes(data, template.encoding) text = decode_bytes(data, template.encoding)
delim = (template.delimiter or ";")[0] delim = (template.delimiter or ";")[0]
reader = csv.reader(io.StringIO(text), delimiter=delim) reader = csv.reader(io.StringIO(text), delimiter=delim)
@@ -135,7 +497,24 @@ def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict:
if template.has_header and len(rows) > body_start: if template.has_header and len(rows) > body_start:
header = rows[body_start] header = rows[body_start]
body_start += 1 body_start += 1
return {"header": header, "rows": rows[body_start: body_start + limit]} body = rows[body_start:]
filterwerte: dict[int, list[dict]] = {}
breite = max((len(r) for r in body), default=0)
for i in range(breite):
zaehler = Counter((r[i] or "").strip() for r in body if i < len(r))
zaehler.pop("", None)
# Wiederholen sich die Werte nicht, ist es keine Gruppe, sondern ein
# Datenfeld wie Empfänger oder Betrag.
if 2 <= len(zaehler) <= MAX_FILTERWERTE and len(zaehler) < len(body):
filterwerte[i] = [{"value": v, "count": n} for v, n in zaehler.most_common()]
return {
"header": header,
"rows": body[:limit],
"row_count": len(body),
"filter_values": filterwerte,
}
def import_hash(account_id: int, d: date | None, amount_cents: int | None, payee: str | None) -> str: def import_hash(account_id: int, d: date | None, amount_cents: int | None, payee: str | None) -> str:
@@ -172,7 +551,7 @@ def _match_rule(
purpose: str, purpose: str,
amount: int | None, amount: int | None,
allowed_kind: CategoryKind | None, allowed_kind: CategoryKind | None,
cat_kind: dict[int, CategoryKind], cat_kind: dict[int, CategoryKind | None],
) -> CategorizationRule | None: ) -> CategorizationRule | None:
"""Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln """Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln
greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt; greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt;
@@ -191,26 +570,55 @@ def _match_rule(
continue continue
if not rule.category_id: if not rule.category_id:
continue continue
if allowed_kind is not None and cat_kind.get(rule.category_id) != allowed_kind: # Neutrale Kategorien („Kontoübertrag") passen in beide Richtungen: Geld
# geht auf dem einen Konto raus und kommt auf dem anderen rein.
art = cat_kind.get(rule.category_id)
if allowed_kind is not None and art is not None and art != allowed_kind:
continue continue
if _pattern_hit(rule, p, z): if _pattern_hit(rule, p, z):
return rule return rule
return None return None
def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpose: str, source_id: int) -> int | None: # Status-Werte, die eine Buchung NICHT wirksam werden lassen. Bewusst als
"""Zielkonto eines Transfers aus dem Buchungstext raten: längster passender # Sperrliste (nicht Positivliste): Bank-Exporte sind lokalisiert – „COMPLETED"
Erkennungs-Text je Konto gewinnt.""" # heißt auf Deutsch „ABGESCHLOSSEN". Unbekannte Werte werden gebucht, damit
hay = f"{normalize_text(payee)} {normalize_text(purpose)}" # keine echten Buchungen still verloren gehen.
best: tuple[int, int] | None = None # (Länge, Konto-ID) UNBOOKABLE_STATES = (
for val, acc_id in text_idents: "PENDING", "AUSSTEHEND", "SCHWEBEND",
if val and acc_id != source_id and val in hay and (best is None or len(val) > best[0]): "REVERTED", "RUECKGAENGIG", "RÜCKGÄNGIG", "STORNIERT", "STORNO",
best = (len(val), acc_id) "DECLINED", "ABGELEHNT",
return best[1] if best else None "FAILED", "FEHLGESCHLAGEN",
"CANCELLED", "CANCELED", "ABGEBROCHEN",
)
def state_is_bookable(state: str | None) -> bool:
s = (state or "").strip().upper()
if not s:
return True
return not any(bad in s for bad in UNBOOKABLE_STATES)
# Fenster, in dem eine Buchung als Gegenseite eines bestehenden Transfers gilt
# (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen).
# Wie weit der tatsächlich gutgeschriebene Betrag vom geschätzten abweichen darf,
# damit er noch als dieselbe Bewegung gilt. Bei einem Währungstausch rechnen wir
# mit dem EZB-Kurs, die Bank nutzt ihren eigenen – ein paar Prozent Unterschied
# sind normal, eine andere Buchung liegt praktisch nie so nah.
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]: def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
"""Zerlegt die Datei anhand des Templates in rohe Kandidaten-Zeilen.""" """Zerlegt die Datei anhand des Templates in rohe Kandidaten-Zeilen.
Nur fuer den DATEI-Import. Die Bankanbindung (bank_sync), der Beleg-Import
(documents) und der Telefon-Kurzbefehl (quick) bauen ihre Kandidaten selbst
und brauchen keine Vorzeichen-Umkehr: Sie bekommen die Richtung vom
Gegenueber mitgeliefert. Was hier ueber amount_mode entschieden wird, gilt
deshalb ausdruecklich nicht fuer sie.
"""
cmap = json.loads(template.column_map or "{}") cmap = json.loads(template.column_map or "{}")
def col(field: str) -> int | None: def col(field: str) -> int | None:
@@ -218,6 +626,7 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
return idx if isinstance(idx, int) and idx >= 0 else None return idx if isinstance(idx, int) and idx >= 0 else None
out: list[dict] = [] out: list[dict] = []
vorheriger_saldo: int | None = None
for r in read_rows(data, template): for r in read_rows(data, template):
def cell(field: str) -> str: def cell(field: str) -> str:
c = col(field) c = col(field)
@@ -234,45 +643,144 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
else: else:
amount = parse_amount(cell("amount"), template.decimal_sep) amount = parse_amount(cell("amount"), template.decimal_sep)
# Getrennte Gebührenspalte (z. B. Revolut) mindert den Betrag.
fee = parse_amount(cell("fee"), template.decimal_sep)
balance = parse_amount(cell("balance"), template.decimal_sep)
# Kreditkarten-Konvention: + ist eine Ausgabe.
#
# Die SALDO-Spalte muss mitgedreht werden, nicht nur der Betrag. Ein
# Auszug, der einen Einkauf als +6 fuehrt, schreibt auch den Saldo so:
# die Schuld steigt. Dreht man nur den Betrag, rechnet reconcile
# „erwartet = Saldo[i] − Saldo[i−1]" gegen die alte Richtung und meldet
# bei JEDER Zeile eine Abweichung – die einzige Kontrollinstanz des
# Imports schluege bei einem korrekten Auszug durchgehend Alarm, und
# der Nutzer lernte, sie zu ueberlesen.
#
# Vor dem Gebuehrenblock, weil der beide Werte weiterverwendet
# (vorheriger_saldo). Sonst mischten sich zwei Vorzeichenwelten.
#
# Die Gebuehr bleibt, wie sie ist: Sie geht ueber abs() ein und mindert
# den Betrag in jedem Fall.
if template.amount_mode == AmountMode.signed_inverted:
if amount is not None:
amount = -amount
if balance is not None:
balance = -balance
if amount is not None and fee:
if amount == 0:
# Reine Gebührenzeile: Ob belastet oder erstattet („Erstattung für
# Transaktion"), steht nicht im Betrag – beide Male 0.00 bei
# gleicher Gebühr. Die Saldo-Spalte weiß es; ohne sie ist eine
# Belastung der Normalfall.
delta = (balance - vorheriger_saldo) if (
balance is not None and vorheriger_saldo is not None
) else None
amount = delta if delta is not None else -abs(fee)
else:
amount -= abs(fee)
if balance is not None:
vorheriger_saldo = balance
out.append({ out.append({
"date": d.isoformat() if d else None, "date": d.isoformat() if d else None,
"amount_cents": amount, "amount_cents": amount,
"payee": cell("payee") or None, "payee": cell("payee") or None,
"purpose": cell("purpose") or None, "purpose": cell("purpose") or None,
"counterparty_iban": normalize_iban(cell("counterparty_iban")) or None, "counterparty_iban": normalize_iban(cell("counterparty_iban")) or None,
"state": (cell("state").strip().upper() or None),
"row_currency": (cell("currency").strip().upper() or None),
# Laufender Kontostand aus der Datei – Grundlage für den Saldo-Abgleich.
"balance_cents": balance,
}) })
return out return out
def detect_rows(db: Session, user: User, account: Account, candidates: list[dict]) -> list[dict]: INBOX_TAGE = 3 # Bank bucht oft ein bis zwei Tage nach der Zahlung
"""Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat.
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik)."""
idents: dict[str, int] = {} # IBAN/Nummer -> Konto (für Transfer via IBAN)
text_idents: list[tuple[str, int]] = [] # freie Erkennungs-Texte -> Konto
for value_norm, acc_id, kind in (
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id, AccountIdentifier.kind)
.join(Account, Account.id == AccountIdentifier.account_id)
.filter(Account.owner_id == user.id)
):
if kind == IdentifierKind.text:
text_idents.append((value_norm, acc_id))
else:
idents[value_norm] = acc_id
def finde_eingangs_treffer(
eingang: list[tuple], verbraucht: set[int], d: date, amount: int
) -> int | None:
"""Passenden, noch nicht verbrauchten Eingangs-Eintrag suchen.
Verglichen wird der Betrag exakt – bei Geld ist Toleranz gefährlich – und
das Datum mit ein paar Tagen Spielraum. Der zeitlich nächste gewinnt, damit
bei wiederkehrenden Beträgen (jeden Tag derselbe Kaffee) die richtige Zeile
getroffen wird.
"""
beste: tuple[int, int] | None = None # (Abstand in Tagen, ID)
for eid, edatum, ebetrag in eingang:
if eid in verbraucht or ebetrag != amount:
continue
abstand = abs((d - edatum).days)
if abstand > INBOX_TAGE:
continue
if beste is None or abstand < beste[0]:
beste = (abstand, eid)
return beste[1] if beste else None
def detect_rows(db: Session, user: User, account: Account, candidates: list[dict], *,
eingang_ohne: InboxSource | None = None,
verbraucht: set[int] | None = None) -> list[dict]:
"""Reichert Kandidaten an: Typ, Auto-Kategorie, Duplikat.
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik).
Konten werden hier NICHT mehr miteinander verbunden – jede Zeile ist
Ausgabe oder Einnahme. Überträge bucht man von Hand.
"""
rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all() rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
rules.sort(key=_rule_sort_key, reverse=True) # längstes/​manuell zuerst rules.sort(key=_rule_sort_key, reverse=True) # längstes/​manuell zuerst
cat_kind: dict[int, CategoryKind] = { # None = neutral: zählt für Ausgabe UND Einnahme (siehe _match_rule).
cid: kind cat_kind: dict[int, CategoryKind | None] = {
for cid, kind in db.query(Category.id, Category.type).filter(Category.owner_id == user.id) cid: (None if neutral else kind)
for cid, kind, neutral in db.query(
Category.id, Category.type, Category.excluded_from_stats
).filter(Category.owner_id == user.id)
} }
existing = { # Duplikate als Multiset zählen: eine Zeile gilt nur dann als Duplikat, wenn
# bereits GENAU SO VIELE identische Buchungen in der DB stehen. So werden echte,
# gleich aussehende Buchungen desselben Tags (z. B. zwei Gutschriften über 324)
# behalten, ein erneuter Import derselben Datei aber weiter erkannt.
existing: Counter = Counter(
h[0] h[0]
for h in db.query(Transaction.import_hash).filter( for h in db.query(Transaction.import_hash).filter(
Transaction.owner_id == user.id, Transaction.import_hash.isnot(None) Transaction.owner_id == user.id, Transaction.import_hash.isnot(None)
) )
} )
seen: set[str] = set() # Neutrale Kategorie („Kontoübertrag"): Buchungen, die nur Geld zwischen
# eigenen Konten verschieben, werden damit kategorisiert statt zu einem
# Übertrag verbunden. Sie verändern die Salden korrekt und bleiben aus der
# Statistik heraus.
neutrale_kategorie = next(
(cid for cid, k in cat_kind.items() if k is None), None
)
# Zahlungen, die schon von aussen erfasst wurden (Kurzbefehl, später Mail).
# Der Import-Schlüssel greift hier NICHT: Der Text vom Telefon ist selten
# wortgleich mit dem des Auszugs, und das Buchungsdatum der Bank liegt oft
# ein bis zwei Tage später. Deshalb wird zusätzlich unscharf verglichen –
# gleicher Betrag, Datum in der Nähe.
frage = db.query(
InboxItem.id, InboxItem.date, InboxItem.amount_cents
).filter(
InboxItem.owner_id == user.id,
InboxItem.account_id == account.id,
InboxItem.status.in_((InboxStatus.booked, InboxStatus.discarded)),
)
if eingang_ohne is not None:
# Zwei Umsätze DERSELBEN Quelle sind nie Dubletten voneinander – dort
# entscheidet die Kennung der Quelle, und die ist exakt. Ohne diese
# Ausnahme verschluckt eine Zahlung über 1.00 die nächste über 1.00
# drei Tage später, und der Saldo weicht ab.
frage = frage.filter(InboxItem.source != eingang_ohne)
eingang = [(eid, edatum, ebetrag) for eid, edatum, ebetrag in frage]
# Der Aufrufer kann die Menge über mehrere Aufrufe hinweg mitführen: Der
# Bankabruf ruft je Zeile einzeln auf, und ohne gemeinsame Menge liesse
# sich derselbe Eintrag beliebig oft verbrauchen.
eingang_verbraucht: set[int] = verbraucht if verbraucht is not None else set()
seen: Counter = Counter()
out: list[dict] = [] out: list[dict] = []
for i, cand in enumerate(candidates): for i, cand in enumerate(candidates):
@@ -283,30 +791,48 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
iban = cand.get("counterparty_iban") iban = cand.get("counterparty_iban")
d = date.fromisoformat(d_iso) if d_iso else None d = date.fromisoformat(d_iso) if d_iso else None
valid = d is not None and amount is not None and amount != 0 # Nur abgeschlossene Buchungen zählen. Revolut liefert auch stornierte
# (REVERTED) und schwebende (PENDING) Zeilen mit – die dürfen den Saldo
# nicht verändern.
state = (cand.get("state") or "").upper()
state_ok = state_is_bookable(state)
# Zeile in fremder Währung (Revolut-Export enthält alle Währungs-Pockets).
row_cur = (cand.get("row_currency") or "").upper()
currency_ok = not row_cur or row_cur == account.currency.upper()
valid = (
d is not None and amount is not None and amount != 0
and state_ok and currency_ok
)
# Jede Zeile ist Ausgabe oder Einnahme – der Import verbindet keine
# Konten mehr selbst. Das Raten des Gegenkontos hat mehr Schaden
# angerichtet als Nutzen gebracht: geratene Ziele, verdrehte Richtungen
# und doppelt gebuchte Bewegungen, wenn beide Auszüge eingelesen wurden.
# Wer zwei Buchungen bewusst verbinden will, bucht den Übertrag von Hand.
ttype = "expense" if (amount or 0) < 0 else "income" ttype = "expense" if (amount or 0) < 0 else "income"
category_id: int | None = None category_id: int | None = None
matched_rule_id: int | None = None matched_rule_id: int | None = None
to_account_id: int | None = None to_account_id: int | None = None
other = idents.get(iban) if iban else None kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
if other is not None and other != account.id: rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
ttype = "transfer" if rule is not None:
to_account_id = other matched_rule_id = rule.id
else: # Frühere Übertrags-Schlagwörter kennzeichnen jetzt die neutrale
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income # Kategorie, statt eine Verbindung zwischen Konten herzustellen.
rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind) category_id = rule.category_id or (neutrale_kategorie if rule.is_transfer else None)
if rule is not None:
matched_rule_id = rule.id
if rule.is_transfer:
ttype = "transfer"
to_account_id = _guess_target_account(text_idents, payee or "", purpose or "", account.id)
else:
category_id = rule.category_id
h = import_hash(account.id, d, amount, payee) h = import_hash(account.id, d, amount, payee)
is_dup = h in existing or h in seen is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB
seen.add(h) seen[h] += 1
dup_grund = "hash" if is_dup else None
if not is_dup and d is not None and amount:
treffer = finde_eingangs_treffer(eingang, eingang_verbraucht, d, amount)
if treffer is not None:
eingang_verbraucht.add(treffer)
is_dup = True
dup_grund = "inbox"
out.append({ out.append({
"index": i, "index": i,
@@ -316,17 +842,111 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
"purpose": purpose or None, "purpose": purpose or None,
"counterparty_iban": iban or None, "counterparty_iban": iban or None,
"valid": valid, "valid": valid,
"skip_reason": (
None if valid or d is None or amount is None
else ("state" if not state_ok else "currency" if not currency_ok else None)
),
"state": state or None,
"row_currency": row_cur or None,
"balance_cents": cand.get("balance_cents"),
"type": ttype, "type": ttype,
"category_id": category_id, "category_id": category_id,
"matched_rule_id": matched_rule_id, "matched_rule_id": matched_rule_id,
"to_account_id": to_account_id, "to_account_id": to_account_id,
"import_hash": h, "import_hash": h,
"is_duplicate": is_dup, "is_duplicate": is_dup,
"duplicate_reason": dup_grund,
# Damit commit_import die geschätzte Gegenseite auf den echten Wert setzt.
"counterpart_txn_id": None,
"include": valid and not is_dup, "include": valid and not is_dup,
}) })
return out return out
def reconcile(rows: list[dict]) -> dict:
"""Gleicht die erkannten Beträge gegen die Saldo-Spalte der Datei ab.
Die Saldo-Spalte ist der Schiedsrichter: Sie sagt für jede Zeile, wie sich der
Kontostand tatsächlich verändert hat. Weicht unser Betrag ab, stimmt die
Spaltenzuordnung oder die Gebühren-/Vorzeichen-Behandlung nicht."""
# Zeilen anderer Währung gehören zu einem anderen Saldo-Verlauf (Revolut legt
# alle Pockets in eine Datei) – die würden die Kette zerreißen.
seq = [
r for r in rows
if r.get("balance_cents") is not None and r.get("skip_reason") != "currency"
]
if len(seq) < 2:
return {"available": False}
def gebucht(r: dict) -> int:
"""Betrag, der im Konto ankommt – 0, wenn die Zeile nirgends landet.
Duplikate und Gegenbuchungen zählen mit: deren Betrag steckt bereits in
einer früher gebuchten Buchung. Eine Zeile, die auf ein Gegenkonto wartet,
zählt dagegen NICHT – sie ist unterwegs verloren, und genau das soll der
Abgleich melden.
Die Richtung kommt aus ``type``, nicht aus dem Vorzeichen: Gebucht wird
am Ende ``abs(betrag)`` plus Art (siehe _make_transaction). Wer eine
Zeile von Ausgabe auf Einnahme stellt, aendert damit, was im Konto
ankommt – und der Abgleich muss dasselbe rechnen, sonst prueft er eine
Buchung, die so nie entsteht.
Fuer frisch geparste Zeilen aendert das nichts: Dort stammt ``type``
selbst aus dem Vorzeichen, beide Wege fuehren zum selben Wert.
"""
if not r.get("valid"):
return 0
betrag = r.get("amount_cents") or 0
art = r.get("type")
if art == "expense":
return -abs(betrag)
if art == "income":
return abs(betrag)
# Uebertrag (oder gar kein Typ): Die Richtung steckt im Vorzeichen –
# negativ heisst, das Geld verlaesst dieses Konto.
return betrag
# Manche Banken exportieren neueste Buchung zuerst – für den Abgleich drehen.
dated = [r["date"] for r in seq if r.get("date")]
if len(dated) >= 2 and dated[0] > dated[-1]:
seq = list(reversed(seq))
opening = seq[0]["balance_cents"] - gebucht(seq[0])
final = seq[-1]["balance_cents"]
computed = opening + sum(gebucht(r) for r in seq)
first_mismatch = None
mismatches = 0
prev = seq[0]["balance_cents"]
for r in seq[1:]:
expected = r["balance_cents"] - prev # was die Bank verbucht hat
actual = gebucht(r) # was wir tatsächlich buchen
if expected != actual:
mismatches += 1
if first_mismatch is None:
first_mismatch = {
"index": r.get("index"),
"date": r.get("date"),
"payee": r.get("payee") or r.get("purpose"),
"expected_cents": expected,
"actual_cents": actual,
}
prev = r["balance_cents"]
return {
"available": True,
"opening_balance_cents": opening,
"final_balance_cents": final,
"computed_final_cents": computed,
"difference_cents": final - computed,
"checked": len(seq) - 1,
"mismatches": mismatches,
"ok": final == computed and mismatches == 0,
"first_mismatch": first_mismatch,
}
def build_preview(db: Session, user: User, account: Account, template: ImportTemplate, data: bytes) -> list[dict]: def build_preview(db: Session, user: User, account: Account, template: ImportTemplate, data: bytes) -> list[dict]:
return detect_rows(db, user, account, parse_file_to_candidates(data, template)) return detect_rows(db, user, account, parse_file_to_candidates(data, template))
@@ -405,19 +1025,26 @@ def _learn_rule(db: Session, user: User, row: dict) -> None:
pattern = normalize_text(payee)[:255] pattern = normalize_text(payee)[:255]
if not pattern: if not pattern:
return return
# Art der gelernten Regel = Art der Kategorie (Einnahme/Ausgabe). Gehört zur
# Eindeutigkeit (owner, feld, wort, kind), damit dasselbe Wort je Art zählt.
ckind = db.query(Category.type).filter(
Category.id == cat, Category.owner_id == user.id
).scalar()
kind = ckind.value if ckind is not None else "expense"
existing = ( existing = (
db.query(CategorizationRule) db.query(CategorizationRule)
.filter( .filter(
CategorizationRule.owner_id == user.id, CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.payee, CategorizationRule.match_field == MatchField.payee,
CategorizationRule.pattern == pattern, CategorizationRule.pattern == pattern,
CategorizationRule.kind == kind,
) )
.first() .first()
) )
if existing is None: if existing is None:
db.add(CategorizationRule( db.add(CategorizationRule(
owner_id=user.id, match_field=MatchField.payee, pattern=pattern, owner_id=user.id, match_field=MatchField.payee, pattern=pattern,
category_id=cat, source=RuleSource.learned, priority=1, hits=1, category_id=cat, kind=kind, source=RuleSource.learned, priority=1, hits=1,
)) ))
# Sichtbar machen (Session ist autoflush=False), damit dasselbe Muster ein # Sichtbar machen (Session ist autoflush=False), damit dasselbe Muster ein
# zweites Mal im selben Import die vorhandene Regel findet statt zu doppeln. # zweites Mal im selben Import die vorhandene Regel findet statt zu doppeln.
@@ -445,11 +1072,12 @@ def _track_and_learn(db: Session, user: User, row: dict) -> None:
_learn_rule(db, user, row) _learn_rule(db, user, row)
def commit_import(db: Session, user: User, account: Account, template_id: int | None, filename: str, rows: list[dict]) -> ImportBatch: def commit_import(db: Session, user: User, account: Account, template_id: int | None, filename: str,
rows: list[dict], preset: str | None = None) -> ImportBatch:
accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)} accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
batch = ImportBatch( batch = ImportBatch(
owner_id=user.id, account_id=account.id, template_id=template_id, owner_id=user.id, account_id=account.id, template_id=template_id,
filename=(filename or "")[:255], row_count=len(rows), preset=(preset or None), filename=(filename or "")[:255], row_count=len(rows),
imported_count=0, status=ImportStatus.committed, imported_count=0, status=ImportStatus.committed,
) )
db.add(batch) db.add(batch)
@@ -468,7 +1096,31 @@ def commit_import(db: Session, user: User, account: Account, template_id: int |
batch.imported_count = imported batch.imported_count = imported
db.commit() db.commit()
# Frisch gebuchte Zeilen auf Überträge prüfen. Läuft NACH dem Buchen, nicht
# währenddessen: Der Import bleibt damit unangetastet, und wenn die Suche
# etwas falsch macht, ist es ein Etikett – keine Buchung.
verknuepft = 0
try:
from . import uebertraege
verknuepft = uebertraege.finde(db, user).get("verknuepft", 0)
except Exception: # Nie den Import daran scheitern lassen
log.exception("Übertrags-Suche nach dem Import fehlgeschlagen")
# Und nach den Verträgen sehen – aus demselben Grund und mit derselben
# Vorsicht: Was hier entsteht, ist ein Etikett. Geht es schief, ist der
# Import trotzdem gelaufen.
try:
from . import abo_erkennung
abo_erkennung.finde(db, user)
except Exception:
log.exception("Vertrags-Zuordnung nach dem Import fehlgeschlagen")
db.refresh(batch) db.refresh(batch)
# Kein Datenbankfeld, sondern die Rueckmeldung dieses Vorgangs: Was still
# verknuepft wurde, soll der Nutzer erfahren. Danach interessiert es
# niemanden mehr.
batch.uebertraege_verknuepft = verknuepft
return batch return batch
+6 -2
View File
@@ -12,13 +12,17 @@ from decimal import Decimal
import httpx import httpx
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import waehrungen
from ..models import ExchangeRate from ..models import ExchangeRate
FEED_90D = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml" FEED_90D = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml"
FEED_FULL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml" FEED_FULL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml"
# Welche Währungen wir speichern (Hauptwährungen des Nutzers: EUR ist Pivot). # Welche Währungen wir speichern. Alle, die der Feed hergibt: Sie werden ohnehin
CURRENCIES = {"CHF"} # schon heruntergeladen und eingelesen: sie wegzuwerfen sparte nichts und hiesse
# nur, dass ein Abo in Dollar später nicht anlegbar wäre. EUR ist der Pivot und
# hat keine eigene Zeile.
CURRENCIES = waehrungen.EZB_WAEHRUNGEN
def _parse(xml_bytes: bytes) -> list[tuple[date, str, str]]: def _parse(xml_bytes: bytes) -> list[tuple[date, str, str]]:
+131
View File
@@ -0,0 +1,131 @@
"""Aus einem Eingangs-Vorschlag eine Buchung machen.
Als Dienst und nicht im Router, weil es zwei Aufrufer gibt: die Oberflaeche
(„Buchen") und die Bankanbindung, die ihre Umsaetze gleich selbst bucht. Zwei
Kopien derselben Umrechnung waeren genau die Stelle, an der Salden auseinander
laufen.
"""
from __future__ import annotations
import logging
from sqlalchemy.orm import Session
from ..models import (
Account,
InboxItem,
InboxSource,
InboxStatus,
Transaction,
TxnType,
User,
)
from . import currency
log = logging.getLogger(__name__)
def quell_kennung(source: InboxSource, external_id: str | None) -> str | None:
"""Die Kennung, wie sie an der Buchung steht – oder None ohne Kennung.
Der Name der Quelle gehoert davor: Eine Nachrichten-Kennung aus dem
Postfach und eine Umsatzkennung der Bank duerfen nie zufaellig gleich
lauten, sonst verschluckt die eine Zahlung die andere.
"""
if not external_id:
return None
return f"{source.value}:{external_id}"[:320]
def gebuchte_mit_kennung(db: Session, user: User,
kennungen: list[str | None]) -> Transaction | None:
"""Die Buchung zu einer dieser Kennungen, falls es sie schon gibt.
Der Weg an ``inbox_items`` vorbei ist der Punkt: Der Eingangs-Eintrag ist
eine Zwischenstation und kann fehlen, die Buchung bleibt.
"""
werte = [k for k in kennungen if k]
if not werte:
return None
return (
db.query(Transaction)
.filter(Transaction.owner_id == user.id, Transaction.source_uid.in_(werte))
.first()
)
def buche(db: Session, user: User, eintrag: InboxItem) -> Transaction | None:
"""Aus einem Vorschlag eine Buchung machen. Ohne Konto geht es nicht."""
if eintrag.status == InboxStatus.booked or eintrag.transaction_id is not None:
return None
if eintrag.account_id is None:
return None
konto = db.get(Account, eintrag.account_id)
if konto is None or konto.owner_id != user.id:
return None
kennung = quell_kennung(eintrag.source, eintrag.external_id)
if kennung is not None:
vorhanden = gebuchte_mit_kennung(db, user, [kennung])
if vorhanden is not None:
# Diese Zahlung ist schon gebucht – unter einem frueheren
# Eingangs-Eintrag, den es nicht mehr gibt. Der Eintrag wird
# trotzdem auf die vorhandene Buchung gezeigt und geschlossen:
# Bliebe er offen, luede er beim naechsten Ansehen zum zweiten
# Buchen ein, und genau das soll die Kennung verhindern.
eintrag.transaction_id = vorhanden.id
eintrag.status = InboxStatus.booked
log.info("Eingang %s zeigt auf die vorhandene Buchung %s (%s)",
eintrag.id, vorhanden.id, kennung)
return None
betrag = abs(eintrag.amount_cents)
txn = Transaction(
owner_id=user.id,
type=TxnType.expense if eintrag.amount_cents < 0 else TxnType.income,
date=eintrag.date,
account_id=konto.id,
amount_cents=betrag,
amount_eur_cents=currency.compute_amount_eur_cents(
db, betrag, konto.currency, eintrag.date),
category_id=eintrag.category_id,
payee=eintrag.payee,
description=eintrag.note,
source_uid=kennung,
)
db.add(txn)
db.flush()
eintrag.transaction_id = txn.id
eintrag.status = InboxStatus.booked
return txn
def suche_uebertraege(db: Session, user: User) -> int:
"""Nach dem Buchen nach Uebertraegen sehen – so wie nach einem Import.
Die Gegenseite eines Uebertrags entsteht erst beim Buchen. Ohne diesen
Aufruf wuerde sie nie gefunden, und beide Seiten zaehlten dauerhaft als
Ausgabe und Einnahme in der Auswertung mit.
Ein Fehler hier darf das Buchen nicht kippen: Die Buchung ist schon
festgeschrieben, die Verknuepfung ist nur eine Schicht darueber.
"""
from . import abo_erkennung, uebertraege
verknuepft = 0
try:
verknuepft = uebertraege.finde(db, user).get("verknuepft", 0)
except Exception: # pragma: no cover - Sicherheitsnetz
log.exception("Uebertragssuche nach dem Buchen fehlgeschlagen")
db.rollback()
# Danach nach Vertraegen sehen. Ein Abo, das ueber die Bank hereinkommt,
# soll sich von selbst seinem Vertrag zuordnen – sonst muesste man nach
# jedem Abruf von Hand nachsehen.
try:
abo_erkennung.finde(db, user)
except Exception: # pragma: no cover - Sicherheitsnetz
log.exception("Vertrags-Zuordnung nach dem Buchen fehlgeschlagen")
db.rollback()
return verknuepft
+45
View File
@@ -0,0 +1,45 @@
"""Verschlüsselung für Zugangsdaten, die Certain im Klartext braucht.
Ein IMAP-Passwort lässt sich nicht hashen: Certain muss es dem Mailserver
vorlegen können. Es liegt deshalb verschlüsselt in der Datenbank – wer die
Datei in die Hände bekommt, hat damit noch nichts.
Der Schlüssel wird aus ``session_secret`` abgeleitet und nirgends gespeichert.
Das hat eine Folge, die im Formular auch so stehen muss: Wird das
Server-Geheimnis getauscht, sind die abgelegten Passwörter unlesbar und die
Postfächer müssen neu eingerichtet werden. Das ist der gewollte Preis dafür,
dass ein Datenbank-Diebstahl allein nicht reicht.
"""
from functools import lru_cache
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from ..config import get_settings
# Trennt diesen Verwendungszweck von jedem anderen, der dasselbe Geheimnis nutzt.
_INFO = b"certain-finance/mail-credentials/v1"
@lru_cache
def _schluessel() -> Fernet:
roh = HKDF(
algorithm=hashes.SHA256(), length=32, salt=None, info=_INFO
).derive(get_settings().session_secret.encode("utf-8"))
import base64
return Fernet(base64.urlsafe_b64encode(roh))
def verschluesseln(klartext: str) -> str:
return _schluessel().encrypt(klartext.encode("utf-8")).decode("ascii")
def entschluesseln(geheim: str) -> str | None:
"""Klartext zurück, oder None wenn der Schlüssel nicht mehr passt."""
try:
return _schluessel().decrypt(geheim.encode("ascii")).decode("utf-8")
except (InvalidToken, ValueError):
return None
+63
View File
@@ -0,0 +1,63 @@
"""Ein hochgeladenes Bild zu einem brauchbaren Logo machen.
Was aus einem Formular kommt, ist selten das, was man anzeigen will: ein
Screenshot vom Telefon, drei Megabyte, 3000 Pixel breit, für eine Kachel von
dreissig. Hier wird daraus ein kleines PNG.
Immer PNG, egal was hereinkam. Das erspart der Anzeige jede Fallunterscheidung
und schliesst SVG aus – eine SVG-Datei kann Skript enthalten, und ein Bild,
das der Nutzer hochlädt und der Browser ausführt, ist genau die Art Falle, die
man sich nicht in ein Finanzprogramm legt.
"""
from __future__ import annotations
import io
from PIL import Image, UnidentifiedImageError
# Was hochgeladen werden darf. Grosszügig – gerechnet wird ohnehin klein.
MAX_UPLOAD = 4 * 1024 * 1024
# Kantenlänge des gespeicherten Bilds. 128 statt 64: Auf einem Bildschirm mit
# doppelter Pixeldichte ist eine 64er-Kachel sonst sichtbar unscharf.
MAX_KANTE = 128
MIME = "image/png"
class LogoFehler(ValueError):
"""Das Bild lässt sich nicht verwenden – mit einem Grund für den Nutzer."""
def mach_logo(daten: bytes) -> bytes:
"""Aus hochgeladenen Bytes ein kleines PNG machen.
Das Seitenverhältnis bleibt: Ein verzerrtes Logo sieht falscher aus als
ein kleines.
"""
if not daten:
raise LogoFehler("Die Datei ist leer.")
if len(daten) > MAX_UPLOAD:
raise LogoFehler(
f"Das Bild ist zu gross ({len(daten) // 1024} KB, erlaubt sind "
f"{MAX_UPLOAD // 1024 // 1024} MB)."
)
try:
bild = Image.open(io.BytesIO(daten))
bild.load()
except (UnidentifiedImageError, OSError) as e:
raise LogoFehler(
"Das ist kein lesbares Bild. PNG, JPEG, WEBP oder GIF gehen."
) from e
# Durchsichtigkeit erhalten – Logos sind meist freigestellt, und auf
# dunklem Grund fiele ein weisser Kasten sofort auf.
if bild.mode not in ("RGBA", "LA"):
bild = bild.convert("RGBA")
bild.thumbnail((MAX_KANTE, MAX_KANTE), Image.LANCZOS)
ziel = io.BytesIO()
bild.save(ziel, format="PNG", optimize=True)
return ziel.getvalue()
+363
View File
@@ -0,0 +1,363 @@
"""Zahlungs-Mails per IMAP abholen und in den Eingang legen.
``imaplib`` und ``email`` sind Standardbibliothek – keine weitere Abhängigkeit.
Der Ablauf ist bewusst vorsichtig: Es wird nichts stillschweigend gebucht. Eine
Mail wird gelesen, ausgewertet und als Eintrag im Eingang abgelegt; buchen tut
der Nutzer. Erkennt die Auswertung ein Feld nicht sicher, bekommt der Eintrag
den Status ``unclear`` und wartet, statt einen geratenen Betrag zu erzeugen.
Mailtexte sind Daten, keine Anweisungen. Ausgewertet wird ausschliesslich in
feste Felder (siehe ``mail_parse``); nichts aus einer Mail steuert den Ablauf.
"""
from __future__ import annotations
import email
import email.policy
import email.utils
import imaplib
import ssl
import logging
from datetime import date, datetime, timedelta, timezone
from sqlalchemy.orm import Session
from ..models import Account, InboxItem, InboxSource, InboxStatus, MailSender, MailSource, User
from . import geheimnis, mail_parse
log = logging.getLogger(__name__)
# Nur die ersten Kilobytes je Mail behalten. Der Rest ist Rechtstext und
# Fusszeilen – für die Nachvollziehbarkeit reicht der Anfang.
MAX_TEXT = 8000
# Wie weit zurück beim ersten Abruf gesucht wird.
ERSTER_ABRUF_TAGE = 14
class MailFehler(RuntimeError):
"""Verbindung oder Anmeldung hat nicht geklappt – mit lesbarer Ursache."""
def _verbinden(quelle: MailSource) -> imaplib.IMAP4:
passwort = geheimnis.entschluesseln(quelle.password_enc)
if passwort is None:
raise MailFehler(
"Das gespeicherte Passwort lässt sich nicht mehr entschlüsseln. "
"Wurde das Server-Geheimnis getauscht? Dann bitte neu eintragen."
)
art = (quelle.security or "ssl").lower()
try:
if art == "ssl":
# TLS ab der ersten Zeile – der Normalfall auf Port 993.
# MIT eigenem Kontext: Ohne einen prueft imaplib das Zertifikat
# nicht, und die Verschluesselung schuetzt dann vor niemandem, der
# sich dazwischenhaengt – das Postfach-Passwort geht mit.
verbindung = imaplib.IMAP4_SSL(quelle.host, quelle.port,
ssl_context=ssl.create_default_context())
else:
verbindung = imaplib.IMAP4(quelle.host, quelle.port)
if art == "starttls":
# Erst im Klartext verbinden, dann verschlüsseln (Port 143) –
# ebenfalls mit geprüftem Zertifikat, aus demselben Grund.
verbindung.starttls(ssl.create_default_context())
except OSError as e:
raise MailFehler(f"Keine Verbindung zu {quelle.host}:{quelle.port} – {e}") from e
except imaplib.IMAP4.error as e:
raise MailFehler(
f"Verschlüsselung fehlgeschlagen ({art}). Passt die Angabe zum Port {quelle.port}? – {e}"
) from e
try:
verbindung.login(quelle.username, passwort)
except imaplib.IMAP4.error as e:
try:
verbindung.logout()
except Exception:
pass
raise MailFehler(f"Anmeldung abgelehnt: {e}") from e
return verbindung
def _ordnername(zeile: bytes) -> str:
"""Aus einer LIST-Antwort den reinen Ordnernamen holen."""
text = zeile.decode("utf-8", "replace")
if '"' in text:
return text.rsplit('"', 2)[-2]
return text.split()[-1]
def liste_ordner(quelle: MailSource) -> list[str]:
"""Ordner des Postfachs – für die Auswahl in der Oberfläche."""
verbindung = _verbinden(quelle)
try:
code, zeilen = verbindung.list()
if code != "OK":
raise MailFehler("Ordnerliste nicht abrufbar")
return sorted({_ordnername(z) for z in zeilen if z})
finally:
try:
verbindung.logout()
except Exception:
pass
def teste(quelle: MailSource) -> dict:
"""Verbindungstest mit klarer Rückmeldung statt stillem Nichtstun."""
ordner = liste_ordner(quelle)
return {"ok": True, "folders": ordner, "count": len(ordner)}
def _text_der_mail(msg) -> tuple[str, str]:
"""Betreff und sichtbarer Text. HTML wird zu Text, wenn nichts anderes da ist."""
betreff = str(msg["Subject"] or "")
teil = msg.get_body(preferencelist=("plain",))
if teil is not None:
try:
return betreff, teil.get_content()
except Exception:
pass
teil = msg.get_body(preferencelist=("html",))
if teil is not None:
try:
return betreff, mail_parse.html_zu_text(teil.get_content())
except Exception:
pass
return betreff, ""
def _absenderfeld(msg) -> str:
"""Alles, was den ursprünglichen Absender verraten kann.
Leitet ein Postfach automatisch weiter, bleibt ``From`` meist der
Originalabsender – verlassen kann man sich darauf aber nicht. Gmail und
andere setzen zusätzlich ``X-Forwarded-For``; bei manueller Weiterleitung
steht die eigene Adresse im ``From`` und die ursprüngliche höchstens im
``Reply-To``. Geprüft wird deshalb alles zusammen.
"""
teile = [str(msg[k] or "") for k in
("From", "Reply-To", "Return-Path", "X-Forwarded-For", "X-Original-From", "Sender")]
return " ".join(t for t in teile if t)
# Wie weit in den Text hinein gesucht wird. Bei einer weitergeleiteten Mail
# steht der Originalabsender im zitierten Kopf ganz oben.
ERKENNUNG_TEXTLAENGE = 2000
def _passender_absender(
absender: list[MailSender], von: str, betreff: str, text: str = "",
waehrung: str | None = None,
) -> MailSender | None:
"""Erste Zuordnung, deren Erkennungstext greift.
Gesucht wird in Absender-Köpfen, Betreff UND dem Anfang des Textes. Wer
eine Bank-Mail von Hand weiterleitet, hat im ``From`` die eigene Adresse;
„Raiffeisen“ steht dann nur noch im Betreff („WG: Raiffeisen …“) und im
zitierten Kopf. Ein Feld, das nach dem Absender fragt und Betreff wie Text
ignoriert, würde genau dort versagen – und der Nutzer säße vor einer Mail,
in der sein Suchwort sichtbar drinsteht.
"""
heu = f"{von}\n{betreff}\n{text[:ERKENNUNG_TEXTLAENGE]}".lower()
betreff_klein = betreff.lower()
# Zuordnungen MIT Währung zuerst prüfen: Wer „revolut + EUR → Revolut Euro"
# anlegt und daneben ein allgemeines „revolut", meint die genauere zuerst.
for a in sorted(absender, key=lambda x: (x.currency is None, x.id)):
if a.from_contains and a.from_contains.lower() not in heu:
continue
if a.subject_contains and a.subject_contains.lower() not in betreff_klein:
continue
if a.currency and (waehrung or "").upper() != a.currency.upper():
continue
return a
return None
# IMAP verlangt die ENGLISCHEN Monatskürzel. ``strftime("%b")`` richtet sich
# nach der Sprache des Servers – auf einem deutschen System käme „Mär", „Mai",
# „Okt", „Dez" heraus, und die Suche fände nichts. Im August fällt das nicht
# auf, weil das Kürzel dort in beiden Sprachen gleich lautet.
MONATE = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
def _imap_datum(tag: date) -> str:
return f"{tag.day:02d}-{MONATE[tag.month - 1]}-{tag.year}"
def _suchkriterium(quelle: MailSource, tage: int | None = None) -> tuple[str, date]:
"""Nach Datum suchen, nie nach „ungelesen".
Auf ``UNSEEN`` zu filtern klingt sparsam, ist aber eine Falle: Wer eine
Mail in seinem Mailprogramm ansieht, bevor Certain sie geholt hat, verliert
sie dauerhaft – sie ist dann für immer gelesen. Gesucht wird deshalb ab dem
letzten Abruf, mit einem Tag Überlappung gegen Zeitzonen-Verschiebungen.
Dass nichts doppelt ankommt, sichert die Message-ID ab.
``tage`` übergeht den letzten Abruf und sucht ab heute minus so vielen
Tagen. Das ist nötig, weil der Zeitstempel auch dann gesetzt wird, wenn ein
Abruf nichts fand: Ältere Mails wären danach für immer ausserhalb des
Fensters, obwohl sie im Postfach liegen.
"""
if tage is not None:
seit = date.today() - timedelta(days=tage)
elif quelle.last_poll_at:
seit = quelle.last_poll_at.date() - timedelta(days=1)
else:
seit = date.today() - timedelta(days=ERSTER_ABRUF_TAGE)
return f'(SINCE "{_imap_datum(seit)}")', seit
def hole_neue(db: Session, quelle: MailSource, tage: int | None = None) -> dict:
"""Neue Mails abholen und als Einträge im Eingang ablegen."""
user = db.get(User, quelle.owner_id)
absender = (
db.query(MailSender).filter(MailSender.source_id == quelle.id).order_by(MailSender.id).all()
)
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == quelle.owner_id)}
verbindung = _verbinden(quelle)
neu = uebersprungen = unklar = gefunden = 0
# Was durchgefallen ist, mit Absender und Betreff – sonst raet man beim
# Einrichten, was in den Filter gehoert.
ohne_zuordnung: list[dict] = []
# Und die, die eine Regel TRAFEN, aber nicht zu lesen waren. Ohne diese
# Liste steht am Ende nur eine Zahl, und man weiss nicht, welche Mail
# gemeint ist oder was ihr fehlt.
unklar_liste: list[dict] = []
try:
code, _ = verbindung.select(f'"{quelle.folder}"')
if code != "OK":
raise MailFehler(f"Ordner „{quelle.folder}“ nicht gefunden")
kriterium, seit = _suchkriterium(quelle, tage)
code, daten = verbindung.search(None, kriterium)
if code != "OK":
raise MailFehler("Suche im Postfach fehlgeschlagen")
uids = (daten[0] or b"").split()
gefunden = len(uids)
for uid in uids:
code, roh = verbindung.fetch(uid, "(RFC822)")
if code != "OK" or not roh or not isinstance(roh[0], tuple):
continue
msg = email.message_from_bytes(roh[0][1], policy=email.policy.default)
message_id = str(msg["Message-Id"] or f"uid-{uid.decode()}")
# Message-ID statt UID: Die UID vergibt der Server beim Verschieben neu.
schon = (
db.query(InboxItem.id)
.filter(InboxItem.owner_id == quelle.owner_id,
InboxItem.source == InboxSource.mail,
InboxItem.external_id == message_id)
.first()
)
if schon is not None:
uebersprungen += 1
_nachbereiten(verbindung, quelle, uid)
continue
betreff, text = _text_der_mail(msg)
von = _absenderfeld(msg)
gesendet = None
if msg["Date"]:
try:
gesendet = email.utils.parsedate_to_datetime(msg["Date"])
except (TypeError, ValueError):
gesendet = None
# Auswerten VOR dem Zuordnen: Bei Revolut kommt dieselbe Mail vom
# selben Absender, und erst die erkannte Währung sagt, ob die
# Zahlung aufs Euro- oder aufs Franken-Konto gehört.
e = mail_parse.parse(betreff, text, gesendet)
erkannte_waehrung = e.waehrung.wert if e.waehrung else None
zuordnung = _passender_absender(absender, von, betreff, text, erkannte_waehrung)
if zuordnung is None:
uebersprungen += 1
if len(ohne_zuordnung) < 5:
ohne_zuordnung.append({
"from": str(msg["From"] or "")[:120],
"subject": betreff[:120],
"currency": erkannte_waehrung,
})
continue
konto = konten.get(zuordnung.account_id) if zuordnung.account_id else None
tag = e.datum.wert if e.datum else (gesendet.date() if gesendet else date.today())
betrag = e.betrag.wert if e.betrag else 0
ausgabe = (e.richtung.wert if e.richtung else "expense") == "expense"
status = InboxStatus.pending
if e.unklar or betrag == 0 or konto is None:
status = InboxStatus.unclear
unklar += 1
gruende = []
if konto is None:
gruende.append("kein Konto zugeordnet")
if betrag == 0:
gruende.append("kein Betrag erkannt")
for teil in e.unklar:
gruende.append({
"richtung": "Richtung unklar (Ein- oder Ausgang?)",
"betrag": "Betrag nicht eindeutig",
"status": "Zahlung noch nicht ausgeführt",
}.get(teil, teil))
unklar_liste.append({"from": von, "subject": betreff,
"gruende": sorted(set(gruende))})
db.add(InboxItem(
owner_id=quelle.owner_id,
source=InboxSource.mail,
status=status,
account_id=konto.id if konto else None,
date=tag,
amount_cents=-betrag if ausgabe else betrag,
currency=(e.waehrung.wert if e.waehrung else (konto.currency if konto else "EUR")),
payee=(e.empfaenger.wert if e.empfaenger else None),
note=betreff[:255] or None,
external_id=message_id[:255],
raw_text=text[:MAX_TEXT],
))
neu += 1
_nachbereiten(verbindung, quelle, uid)
quelle.last_poll_at = datetime.now(timezone.utc)
quelle.last_error = None
db.commit()
finally:
try:
verbindung.logout()
except Exception:
pass
# Das Suchdatum gehoert in die Antwort: Findet sich nichts, ist die Frage
# nicht "stimmt der Ordner", sondern "wie weit wurde ueberhaupt gesucht".
return {"gefunden": gefunden, "neu": neu, "uebersprungen": uebersprungen,
"unklar": unklar, "seit": seit.isoformat(),
"ohne_zuordnung": ohne_zuordnung, "unklar_liste": unklar_liste}
def _nachbereiten(verbindung: imaplib.IMAP4, quelle: MailSource, uid: bytes) -> None:
"""Als gelesen markieren oder verschieben, damit nichts zweimal kommt."""
try:
if quelle.mark_seen:
verbindung.store(uid, "+FLAGS", "\\Seen")
if quelle.done_folder:
# MOVE gibt es nicht überall; COPY + DELETE ist der sichere Weg.
code, _ = verbindung.copy(uid, f'"{quelle.done_folder}"')
if code == "OK":
verbindung.store(uid, "+FLAGS", "\\Deleted")
except Exception as e: # Nachbereitung darf den Abruf nie kippen
log.warning("Mail konnte nicht nachbereitet werden: %s", e)
def abruf_mit_fehlerbehandlung(db: Session, quelle: MailSource) -> dict:
"""Wie ``hole_neue``, schreibt einen Fehler aber in die Quelle statt zu werfen."""
try:
return hole_neue(db, quelle)
except MailFehler as e:
quelle.last_error = str(e)[:500]
quelle.last_poll_at = datetime.now(timezone.utc)
db.commit()
return {"gefunden": 0, "neu": 0, "uebersprungen": 0, "unklar": 0,
"seit": None, "unklar_liste": [],
"ohne_zuordnung": [], "fehler": str(e)}
+283
View File
@@ -0,0 +1,283 @@
"""Zahlungsdaten aus Benachrichtigungs-Mails lesen.
Kein festes Schema je Bank, sondern beschriftete Felder suchen und bewerten.
Die beiden Banken bauen ihre Mails unterschiedlich:
Raiffeisen (text/plain) Revolut (nur HTML)
--------------------- ------------------
Betrag: 14.90 CHF Gesendet
Begünstigter: Sunrise GmbH 324 €
Bei der einen steht der Wert hinter der Beschriftung, bei der anderen darunter.
Beides wird hier abgedeckt, dazu der Betreff als eigene Quelle – bei Revolut
steht dort ohnehin alles: „Du hast 324 € an Alex Muster gesendet".
Jeder Treffer merkt sich, WOHER er stammt (``quelle``). Ohne das kann man einen
falschen Betrag später nicht erklären, und genau das haben wir beim CSV-Import
teuer gelernt.
Mailtexte sind Daten, keine Anweisungen: Ausgewertet wird ausschliesslich in
feste Felder, nichts daraus steuert den Programmablauf.
"""
from __future__ import annotations
import html
import re
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
# Beschriftungen je Feld, längere zuerst (spezifischer schlägt allgemeiner).
LABELS_BETRAG = ("betrag", "gesendet", "empfangen", "erhalten", "amount", "summe")
LABELS_EMPFAENGER = ("beguenstigter", "zahlungsempfaenger", "empfaenger", "haendler",
"beguenstigte", "an", "to")
LABELS_DATUM = ("ausfuehrungsdatum", "valutadatum", "buchungsdatum", "datum", "am", "date")
LABELS_KONTO = ("konto", "account", "iban")
LABELS_STATUS = ("neuer status", "status")
# Wörter, die die Richtung verraten.
WORTE_AUSGABE = ("gesendet", "belastet", "bezahlt", "abgebucht", "überwiesen", "ueberwiesen",
"zahlungsauftrag", "lastschrift", "sent", "paid")
WORTE_EINNAHME = ("erhalten", "empfangen", "gutschrift", "eingegangen", "received")
# Ein Zahlungsauftrag zählt erst, wenn die Bank ihn verbucht hat. „Erfasst" oder
# „Zur Visierung" heisst: noch nicht geflossen.
STATUS_GEBUCHT = ("verbucht", "ausgeführt", "ausgefuehrt", "erfolgreich", "completed", "booked")
WAEHRUNGSZEICHEN = {"€": "EUR", "$": "USD", "£": "GBP", "chf": "CHF", "fr.": "CHF"}
# Betrag mit Währung davor oder dahinter: „14.90 CHF", „324 €", „CHF 1'234.50".
_BETRAG = re.compile(
r"(?:(?P<vor>CHF|EUR|USD|GBP|€|\$|£)\s*)?"
r"(?P<zahl>\d{1,3}(?:['’ ]\d{3})*(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?)"
r"(?:\s*(?P<nach>CHF|EUR|USD|GBP|€|\$|£))?",
re.IGNORECASE,
)
_DATUM_MUSTER = (
(re.compile(r"\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b"), "dmy"),
(re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b"), "ymd"),
(re.compile(r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b"), "dmy"),
)
@dataclass
class Feld:
"""Ein erkannter Wert samt Begründung, woher er stammt."""
wert: object
quelle: str # menschenlesbar, z. B. „aus dem Text, nach ‚Betrag'"
sicher: bool = True
@dataclass
class Erkennung:
betrag: Feld | None = None # Cent, vorzeichenlos
waehrung: Feld | None = None
datum: Feld | None = None
empfaenger: Feld | None = None
richtung: Feld | None = None # "expense" | "income"
konto_text: Feld | None = None # Kontobezeichnung aus der Mail
unklar: list[str] = field(default_factory=list)
def vollstaendig(self) -> bool:
return self.betrag is not None and self.datum is not None and self.richtung is not None
def html_zu_text(roh: str) -> str:
"""Sichtbaren Text aus HTML gewinnen.
Stil- und Skriptblöcke MÜSSEN vorher weg – sonst landen Kilobytes CSS im
Text, und darin stehen Zahlen, die wie Beträge aussehen.
"""
txt = re.sub(r"(?is)<(style|script|head)[^>]*>.*?</\1>", " ", roh)
txt = re.sub(r"(?is)<!--.*?-->", " ", txt)
txt = re.sub(r"(?is)<br\s*/?>|</(p|div|tr|td|th|h[1-6]|li)>", "\n", txt)
txt = re.sub(r"(?s)<[^>]+>", " ", txt)
txt = html.unescape(txt)
zeilen = [re.sub(r"[ \t\xa0]+", " ", z).strip() for z in txt.splitlines()]
return "\n".join(z for z in zeilen if z)
def _schluessel(text: str) -> str:
"""Beschriftung auf eine vergleichbare Form bringen.
Umlaute verschriftlichen und alles ausser Buchstaben und Ziffern
wegwerfen. „Begünstigter", „Beguenstigter" und „BEGÜNSTIGTER :" ergeben
damit denselben Schlüssel – sonst haengt die Erkennung daran, wie das
Mailprogramm den Umlaut kodiert hat.
"""
t = text.strip().lower()
for von, nach in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
t = t.replace(von, nach)
return re.sub(r"[^a-z0-9]", "", t)
def _zahl_zu_cent(roh: str) -> int | None:
s = roh.replace("'", "").replace("’", "").replace(" ", "")
if "," in s and "." in s:
s = s.replace(".", "").replace(",", ".") if s.rindex(",") > s.rindex(".") else s.replace(",", "")
elif "," in s:
s = s.replace(",", ".")
try:
return int((Decimal(s) * 100).to_integral_value())
except (InvalidOperation, ValueError):
return None
def _betraege(text: str) -> list[tuple[int, str | None, int]]:
"""Alle geldartigen Vorkommen als (Cent, Währung, Position)."""
treffer = []
for m in _BETRAG.finditer(text):
zeichen = m.group("vor") or m.group("nach")
if zeichen is None:
continue # blosse Zahlen sind keine Beträge
cent = _zahl_zu_cent(m.group("zahl"))
if cent is None or cent == 0:
continue
w = WAEHRUNGSZEICHEN.get(zeichen.lower(), zeichen.upper())
treffer.append((cent, w, m.start()))
return treffer
def _felder(text: str) -> dict[str, list[tuple[str, int, str]]]:
"""Beschriftete Werte einsammeln – beide Bauformen.
``Betrag: 14.90 CHF`` liefert den Wert hinter dem Doppelpunkt,
``Gesendet`` / ``324 €`` den Inhalt der Folgezeile.
"""
gefunden: dict[str, list[tuple[str, int, str]]] = {}
# Weitergeleitete Mails kommen oft mit „> " zitiert an; die Beschriftung
# steht dann nicht mehr am Zeilenanfang.
zeilen = [re.sub(r"^[\s>]+", "", z) for z in text.splitlines()]
pos = 0
for i, zeile in enumerate(zeilen):
start = pos
pos += len(zeile) + 1
if ":" in zeile:
links, rechts = zeile.split(":", 1)
beschriftung = links.strip()
schl, wert = _schluessel(beschriftung), rechts.strip()
if wert:
gefunden.setdefault(schl, []).append((wert, start, beschriftung))
continue
beschriftung = zeile.strip().rstrip(":")
schl = _schluessel(beschriftung)
if schl and len(schl) <= 30 and i + 1 < len(zeilen):
folge = zeilen[i + 1].strip()
if folge and ":" not in folge:
gefunden.setdefault(schl, []).append((folge, pos, beschriftung))
return gefunden
def _erster_treffer(felder: dict, labels: tuple[str, ...]) -> tuple[str, int, str] | None:
"""Wert, Stelle und die Beschriftung in Originalschreibweise.
Die Suchbegriffe laufen durch dieselbe Normalisierung wie die gefundenen
Beschriftungen – sonst muesste man sie doppelt pflegen, einmal lesbar und
einmal fuer den Vergleich.
"""
for roh in labels:
label = _schluessel(roh)
if label in felder:
wert, stelle, beschriftung = felder[label][0]
return wert, stelle, beschriftung
return None
def _datum_aus(text: str) -> date | None:
for muster, form in _DATUM_MUSTER:
m = muster.search(text)
if not m:
continue
a, b, c = (int(x) for x in m.groups())
try:
return date(c, b, a) if form == "dmy" else date(a, b, c)
except ValueError:
continue
return None
def parse(betreff: str, text: str, mail_datum: datetime | None = None) -> Erkennung:
"""Betrag, Datum, Empfänger und Richtung aus einer Mail lesen."""
e = Erkennung()
felder = _felder(text)
ganzes = f"{betreff}\n{text}"
klein = ganzes.lower()
# --- Richtung -----------------------------------------------------------
for wort in WORTE_EINNAHME:
if wort in klein:
e.richtung = Feld("income", f"Wort „{wort}“")
break
if e.richtung is None:
for wort in WORTE_AUSGABE:
if wort in klein:
e.richtung = Feld("expense", f"Wort „{wort}“")
break
if e.richtung is None:
e.richtung = Feld("expense", "Vorgabe: Ausgabe", sicher=False)
e.unklar.append("richtung")
# --- Betrag: beschriftetes Feld schlägt Fund im Fliesstext --------------
treffer = _erster_treffer(felder, LABELS_BETRAG)
if treffer:
wert, _, label = treffer
gefunden = _betraege(wert)
if gefunden:
cent, waehrung, _ = gefunden[0]
e.betrag = Feld(cent, f"nach „{label}“")
if waehrung:
e.waehrung = Feld(waehrung, f"nach „{label}“")
if e.betrag is None:
aus_betreff = _betraege(betreff)
kandidaten = aus_betreff or _betraege(text)
quelle = "aus dem Betreff" if aus_betreff else "aus dem Text"
if len(kandidaten) == 1 or (kandidaten and aus_betreff):
cent, waehrung, _ = kandidaten[0]
e.betrag = Feld(cent, quelle)
if waehrung:
e.waehrung = Feld(waehrung, quelle)
elif kandidaten:
# Mehrere gleichwertige Beträge: raten wäre hier teuer.
e.unklar.append("betrag")
# --- Datum --------------------------------------------------------------
treffer = _erster_treffer(felder, LABELS_DATUM)
if treffer:
wert, _, label = treffer
d = _datum_aus(wert)
if d is not None:
e.datum = Feld(d, f"nach „{label}“")
if e.datum is None:
d = _datum_aus(text)
if d is not None:
e.datum = Feld(d, "aus dem Text")
elif mail_datum is not None:
# Revolut schreibt „Samstag, 1 August, 12:41“ – ohne Jahr. Der
# Zeitstempel der Mail ist bei Sofort-Mitteilungen zuverlässiger
# als ein zusammengeratenes Datum.
e.datum = Feld(mail_datum.date(), "Zeitpunkt der E-Mail")
# --- Empfänger ----------------------------------------------------------
treffer = _erster_treffer(felder, LABELS_EMPFAENGER)
if treffer:
wert, _, label = treffer
e.empfaenger = Feld(wert[:255], f"nach „{label}“")
# --- Kontobezeichnung ---------------------------------------------------
treffer = _erster_treffer(felder, LABELS_KONTO)
if treffer:
wert, _, label = treffer
e.konto_text = Feld(wert[:255], f"nach „{label}“")
# --- Status: nur verbuchte Zahlungen zählen -----------------------------
treffer = _erster_treffer(felder, LABELS_STATUS)
if treffer:
wert, _, label = treffer
if not any(g in wert.lower() for g in STATUS_GEBUCHT):
e.unklar.append("status")
if e.betrag is None:
e.unklar.append("betrag") if "betrag" not in e.unklar else None
return e
+157 -2
View File
@@ -7,6 +7,7 @@ bewusst Best-Effort – das Ergebnis landet in derselben Prüfansicht wie der
CSV-Import und wird dort korrigiert. CSV-Import und wird dort korrigiert.
""" """
import base64
import io import io
import re import re
from datetime import datetime from datetime import datetime
@@ -64,6 +65,8 @@ def extract_text(data: bytes, filename: str, content_type: str) -> str:
DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2}|\d{1,2}[./]\d{1,2}[./]\d{2,4})\b") DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2}|\d{1,2}[./]\d{1,2}[./]\d{2,4})\b")
# Betrag: entweder mit Tausender-Gruppen (1'234.56 / 1.234,56) oder schlicht (3000.00). # Betrag: entweder mit Tausender-Gruppen (1'234.56 / 1.234,56) oder schlicht (3000.00).
AMOUNT_RE = re.compile(r"[-+]?(?:\d{1,3}(?:['.,\s]\d{3})+|\d+)[.,]\d{2}(?!\d)") AMOUNT_RE = re.compile(r"[-+]?(?:\d{1,3}(?:['.,\s]\d{3})+|\d+)[.,]\d{2}(?!\d)")
VALUE_DATE_RE = re.compile(r"value\s*date\s*[:\s]\s*(\d{1,2}[./]\d{1,2}[./]\d{2,4}|\d{4}-\d{2}-\d{2})", re.I)
IBAN_RE = re.compile(r"IBAN[:\s]+([A-Z]{2}[A-Z0-9]{8,32})", re.I)
_DATE_FORMATS = ["%Y-%m-%d", "%d.%m.%Y", "%d.%m.%y", "%d/%m/%Y", "%d/%m/%y"] _DATE_FORMATS = ["%Y-%m-%d", "%d.%m.%Y", "%d.%m.%y", "%d/%m/%Y", "%d/%m/%y"]
@@ -93,8 +96,120 @@ def _parse_amount_auto(s: str) -> int | None:
return -cents if neg else cents return -cents if neg else cents
def parse_transactions(text: str) -> list[dict]: def _line_amount(line: str) -> int | None:
"""Findet Zeilen mit Datum + Betrag und macht daraus Kandidaten-Buchungen.""" """Betrag, falls die ZEILE im Wesentlichen nur ein Betrag ist (evtl. mit
Währungssymbol/-code), z. B. „-97,00€" oder „1'234.56 CHF". Sonst None."""
s = re.sub(r"\s*(€|EUR|CHF|Fr\.?|\$|USD)\s*$", "", line.strip(), flags=re.I).strip()
if AMOUNT_RE.fullmatch(s):
return _parse_amount_auto(s)
return None
def _line_date(line: str):
"""Datum, falls die ZEILE nur aus einem Datum besteht (Buchungsdatum)."""
s = line.strip()
if DATE_RE.fullmatch(s):
return _norm_date(s)
return None
def _filter_noise(raw_lines: list[str]) -> list[str]:
"""Entfernt wiederkehrende Kopf-/Fußzeilen von Kontoauszügen (Name/Adresse/
eigene IBAN im Seitenkopf, „Issued on" + Ausstellungsdatum, Seitenzahl,
Spaltenüberschriften)."""
lines = [ln for ln in raw_lines if ln]
if not lines:
return []
# Halter-Kopf: die Zeilen bis vor „Issued on" (Name, Adresse, eigene IBAN).
holder: set[str] = set()
for ln in lines[:6]:
if ln.lower().startswith("issued on"):
break
holder.add(ln)
heads = {"description", "booking date", "amount", "account activity"}
out: list[str] = []
skip_date = False
for ln in lines:
low = ln.lower()
if skip_date:
skip_date = False
if _line_date(ln): # das Ausstellungsdatum direkt nach „Issued on"
continue
if ln in holder or low in heads:
continue
if low.startswith("issued on"):
skip_date = True
continue
if re.fullmatch(r"\d+\s*/\s*\d+", ln): # Seitenzahl „1 / 34"
continue
out.append(ln)
return out
def _make_candidate(block: list[str], amount: int) -> dict:
"""Baut aus den angesammelten Beschreibungszeilen + Betrag eine Kandidaten-
Buchung. Buchungsdatum = letzte reine Datumszeile (sonst Value-/Erst-Datum)."""
d = None
used: set[int] = set()
for j in range(len(block) - 1, -1, -1):
dd = _line_date(block[j])
if dd:
d, _ = dd, used.add(j)
break
if d is None:
for j, l in enumerate(block):
vm = VALUE_DATE_RE.search(l)
if vm:
d, _ = _norm_date(vm.group(1)), used.add(j)
break
if d is None:
for j, l in enumerate(block):
gm = DATE_RE.search(l)
if gm:
d, _ = _norm_date(gm.group(1)), used.add(j)
break
iban = None
parts: list[str] = []
for j, l in enumerate(block):
if j in used or VALUE_DATE_RE.search(l):
continue
m = IBAN_RE.search(l)
if m and iban is None:
iban = re.sub(r"[^A-Za-z0-9]", "", m.group(1)).upper()
continue
parts.append(l)
payee = parts[0] if parts else None
purpose = " · ".join(parts[1:]) if len(parts) > 1 else None
return {
"date": d.isoformat() if d else None,
"amount_cents": amount,
"payee": payee[:120] if payee else None,
"purpose": purpose[:200] if purpose else None,
"counterparty_iban": iban,
}
def _parse_blocks(text: str) -> list[dict]:
"""Blockweise: jede Zeile, die im Wesentlichen NUR ein Betrag ist, schließt
eine Buchung ab; die Zeilen davor sind Beschreibung/Datum/IBAN. Passt zu
mehrzeiligen Auszügen (N26 & Co.), bei denen Datum und Betrag eigene Zeilen sind."""
lines = _filter_noise([ln.strip() for ln in text.splitlines()])
rows: list[dict] = []
block: list[str] = []
for ln in lines:
amt = _line_amount(ln)
if amt is None or amt == 0:
block.append(ln)
continue
rows.append(_make_candidate(block, amt))
block = []
return rows
def _parse_lines(text: str) -> list[dict]:
"""Fallback: Datum UND Betrag in derselben Zeile (einfache Tabellen)."""
rows: list[dict] = [] rows: list[dict] = []
for line in text.splitlines(): for line in text.splitlines():
line = line.strip() line = line.strip()
@@ -125,3 +240,43 @@ def parse_transactions(text: str) -> list[dict]:
"counterparty_iban": None, "counterparty_iban": None,
}) })
return rows return rows
def parse_transactions(text: str) -> list[dict]:
"""Kandidaten-Buchungen aus dem Beleg-Text. Erst blockbasiert (mehrzeilig),
sonst der einfache Ein-Zeilen-Fallback."""
rows = _parse_blocks(text)
valid = [r for r in rows if r.get("date") and r.get("amount_cents")]
if valid:
return rows
return _parse_lines(text)
# --- Seiten als Bild (zur Kontrolle in der Prüfansicht) --------------------
def render_pages(data: bytes, filename: str, content_type: str,
max_pages: int = 6, dpi: int = 110) -> list[str]:
"""Rendert die Beleg-Seiten als PNG-Data-URLs (PDF via PyMuPDF, ohne poppler).
Bilder werden unverändert als Data-URL zurückgegeben. Best-Effort."""
name = (filename or "").lower()
out: list[str] = []
is_pdf = name.endswith(".pdf") or content_type == "application/pdf"
try:
if is_pdf:
if fitz is None:
return []
with fitz.open(stream=data, filetype="pdf") as doc:
for k, page in enumerate(doc):
if k >= max_pages:
break
pix = page.get_pixmap(dpi=dpi)
b64 = base64.b64encode(pix.tobytes("png")).decode("ascii")
out.append(f"data:image/png;base64,{b64}")
else:
mime = content_type or "image/png"
b64 = base64.b64encode(data).decode("ascii")
out.append(f"data:{mime};base64,{b64}")
except Exception: # noqa: BLE001 – Vorschau ist optional
return out
return out
+268
View File
@@ -0,0 +1,268 @@
"""OpenID Connect: Anmeldung ueber einen fremden Anbieter (Authorization Code Flow).
Geprueft wird mit Authlib, nicht von Hand. Ein ID-Token selbst zu zerlegen und
seine Signatur nachzurechnen ist die Art Code, die in neunzig Prozent der
Faelle funktioniert und in den uebrigen zehn jemanden hereinlaesst, der nicht
hereingehoert.
Was hier passiert und warum:
``state`` Verknuepft die Rueckkehr mit dem Aufbruch. Ohne ihn koennte jemand
eine fremde Rueckleitung in den Browser des Nutzers schieben.
``nonce`` Steht im ID-Token und wird gegen den gemerkten Wert geprueft. Ohne
ihn liesse sich ein anderswo erbeutetes, gueltiges Token einreichen.
``PKCE`` Bindet den Code an den, der ihn angefordert hat. Bei einem
vertraulichen Client (Secret) nicht zwingend, aber kostenlos – und
der Code bleibt derselbe, falls einmal ein oeffentlicher Client
dazukommt.
Discovery und Schluessel werden kurz zwischengespeichert. Nicht laenger: Ein
Anbieter wechselt seine Signaturschluessel, und ein zu lange gehaltener
Schluesselsatz weist danach jeden ab.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
import time
from dataclasses import dataclass
import httpx
from authlib.jose import JsonWebKey, JsonWebToken
from authlib.jose.errors import JoseError
from ..oidc_config import Provider
log = logging.getLogger(__name__)
ZEITLIMIT = 15.0
# So lange gelten Discovery-Dokument und Schluesselsatz als frisch.
CACHE_SEKUNDEN = 600
# Wie viel Uhrzeit-Unterschied zwischen uns und dem Anbieter geduldet wird.
TOLERANZ_SEKUNDEN = 60
# Nur asymmetrische Verfahren. "none" waere keine Signatur, und HS256 pruefte
# gegen das Client-Secret – beides gehoert hier nicht hin.
ERLAUBTE_ALGORITHMEN = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256"]
class OidcFehler(RuntimeError):
"""Fehler mit einem Grund, den man dem Nutzer zeigen kann."""
@dataclass
class Anmeldung:
"""Was nach erfolgreicher Pruefung ueber den Anmeldenden feststeht."""
subject: str
email: str | None
email_verified: bool
name: str | None
preferred_username: str | None
_cache: dict[str, tuple[float, dict]] = {}
_schluessel_cache: dict[str, tuple[float, object]] = {}
def _hole(url: str) -> dict:
try:
with httpx.Client(timeout=ZEITLIMIT, follow_redirects=True) as c:
antwort = c.get(url)
antwort.raise_for_status()
return antwort.json()
except httpx.HTTPError as e:
raise OidcFehler(f"Der Anmeldedienst ist nicht erreichbar: {e}") from e
except ValueError as e:
raise OidcFehler("Der Anmeldedienst antwortet unverstaendlich.") from e
def discovery(p: Provider) -> dict:
"""Das Discovery-Dokument – gecacht."""
eintrag = _cache.get(p.name)
if eintrag and eintrag[0] > time.time():
return eintrag[1]
daten = _hole(p.discovery_url)
for pflicht in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"):
if not daten.get(pflicht):
raise OidcFehler(
f"Das Discovery-Dokument von '{p.name}' hat kein '{pflicht}'. "
f"Zeigt die Adresse wirklich auf eine OpenID-Konfiguration?"
)
_cache[p.name] = (time.time() + CACHE_SEKUNDEN, daten)
return daten
def _schluesselsatz(p: Provider, jwks_uri: str, erzwinge_neu: bool = False):
eintrag = _schluessel_cache.get(p.name)
if eintrag and eintrag[0] > time.time() and not erzwinge_neu:
return eintrag[1]
satz = JsonWebKey.import_key_set(_hole(jwks_uri))
_schluessel_cache[p.name] = (time.time() + CACHE_SEKUNDEN, satz)
return satz
def cache_leeren() -> None:
_cache.clear()
_schluessel_cache.clear()
# --- Schritt 1: hinschicken --------------------------------------------------
def _pkce() -> tuple[str, str]:
"""(verifier, challenge) nach S256."""
verifier = secrets.token_urlsafe(64)
rumpf = hashlib.sha256(verifier.encode("ascii")).digest()
return verifier, base64.urlsafe_b64encode(rumpf).rstrip(b"=").decode("ascii")
def start(p: Provider, redirect_uri: str) -> dict:
"""Adresse, zu der der Nutzer geschickt wird – plus was wir uns merken muessen."""
from urllib.parse import urlencode
daten = discovery(p)
verifier, challenge = _pkce()
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
frage = {
"response_type": "code",
"client_id": p.client_id,
"redirect_uri": redirect_uri,
"scope": p.scopes,
"state": state,
"nonce": nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
return {
"url": f"{daten['authorization_endpoint']}?{urlencode(frage)}",
"state": state,
"nonce": nonce,
"code_verifier": verifier,
}
# --- Schritt 2: zurueckkommen ------------------------------------------------
def _tausche_code(p: Provider, daten: dict, code: str, redirect_uri: str,
code_verifier: str) -> dict:
formular = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": p.client_id,
"code_verifier": code_verifier,
}
kopfzeilen = {"Accept": "application/json"}
# Ein oeffentlicher Client hat kein Secret und darf auch keines schicken.
auth = (p.client_id, p.client_secret) if p.client_secret else None
if auth is None:
formular.pop("client_id", None)
formular["client_id"] = p.client_id
try:
with httpx.Client(timeout=ZEITLIMIT) as c:
antwort = c.post(daten["token_endpoint"], data=formular,
headers=kopfzeilen, auth=auth)
except httpx.HTTPError as e:
raise OidcFehler(f"Der Anmeldedienst ist nicht erreichbar: {e}") from e
if antwort.status_code >= 400:
grund = ""
try:
inhalt = antwort.json()
grund = inhalt.get("error_description") or inhalt.get("error") or ""
except ValueError:
pass
raise OidcFehler(
f"Der Anmeldedienst hat den Code abgelehnt{': ' + grund if grund else '.'}"
)
try:
return antwort.json()
except ValueError as e:
raise OidcFehler("Der Anmeldedienst antwortet unverstaendlich.") from e
def _pruefe_id_token(p: Provider, daten: dict, id_token: str, nonce: str) -> dict:
"""Signatur, Aussteller, Empfaenger, Laufzeit und nonce.
Bei einem unbekannten Schluessel wird der Satz EINMAL neu geholt: Anbieter
tauschen ihre Signaturschluessel aus, und ein gecachter Satz wiese danach
jeden ab, bis der Zwischenspeicher von selbst ablaeuft.
"""
anspruch = {
"iss": {"essential": True, "value": daten["issuer"]},
"aud": {"essential": True, "value": p.client_id},
"exp": {"essential": True},
}
def _lauf(erzwinge_neu: bool) -> dict:
satz = _schluesselsatz(p, daten["jwks_uri"], erzwinge_neu)
jwt = JsonWebToken(ERLAUBTE_ALGORITHMEN)
gelesen = jwt.decode(id_token, satz, claims_options=anspruch)
gelesen.validate(leeway=TOLERANZ_SEKUNDEN)
return gelesen
try:
gelesen = _lauf(False)
except (JoseError, ValueError):
try:
gelesen = _lauf(True)
except JoseError as e:
raise OidcFehler(f"Das Token des Anmeldedienstes ist ungueltig: {e}") from e
except ValueError as e:
raise OidcFehler("Das Token des Anmeldedienstes ist unlesbar.") from e
# Der nonce wird von Authlib nicht mitgeprueft – hier von Hand, sonst
# liesse sich ein anderswo erbeutetes, gueltiges Token einreichen.
if nonce and gelesen.get("nonce") != nonce:
raise OidcFehler("Die Antwort gehoert nicht zu dieser Anmeldung (nonce).")
if not gelesen.get("sub"):
raise OidcFehler("Das Token nennt keinen Nutzer (sub fehlt).")
return dict(gelesen)
def schliesse_ab(p: Provider, code: str, redirect_uri: str, code_verifier: str,
nonce: str) -> Anmeldung:
"""Code einloesen, ID-Token pruefen, und sagen wer da hereinkommt."""
daten = discovery(p)
tokens = _tausche_code(p, daten, code, redirect_uri, code_verifier)
id_token = tokens.get("id_token")
if not id_token:
raise OidcFehler(
"Der Anmeldedienst hat kein ID-Token geschickt. Fehlt der Scope "
"'openid' in der Konfiguration?"
)
anspruch = _pruefe_id_token(p, daten, id_token, nonce)
email = anspruch.get("email")
verifiziert = bool(anspruch.get("email_verified"))
# Manche Anbieter liefern die E-Mail erst am userinfo-Endpunkt.
if not email and daten.get("userinfo_endpoint") and tokens.get("access_token"):
try:
with httpx.Client(timeout=ZEITLIMIT) as c:
antwort = c.get(
daten["userinfo_endpoint"],
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
if antwort.status_code < 400:
weiteres = antwort.json()
# Nur vom selben Nutzer – sonst mischten wir zwei Identitaeten.
if weiteres.get("sub") == anspruch["sub"]:
email = weiteres.get("email") or email
verifiziert = bool(weiteres.get("email_verified")) or verifiziert
anspruch.setdefault("name", weiteres.get("name"))
anspruch.setdefault("preferred_username",
weiteres.get("preferred_username"))
except (httpx.HTTPError, ValueError):
log.info("userinfo von '%s' nicht abrufbar – ID-Token genuegt", p.name)
return Anmeldung(
subject=str(anspruch["sub"]),
email=(email or "").strip().lower() or None,
email_verified=verifiziert,
name=anspruch.get("name"),
preferred_username=anspruch.get("preferred_username"),
)
+145
View File
@@ -0,0 +1,145 @@
"""Regeln nachträglich auf Buchungen ohne Kategorie anwenden.
Regeln greifen beim Import. Alles, was vorher hereinkam – oder hereinkam,
bevor es die Regel gab –, bleibt liegen. Genau das passiert bei einer
Bankanbindung dauernd: Die Umsätze kommen von selbst, die Regel entsteht
später.
Übertrags-Regeln laufen durch denselben Dienst wie jeder andere Übertrag.
Er lehnt ab, wo die Gegenseite schon gebucht ist; solche Buchungen bleiben
unangetastet und werden gemeldet. Geraten wird nichts.
"""
from __future__ import annotations
from sqlalchemy import select, union
from sqlalchemy.orm import Session
from ..models import (
Account,
CategorizationRule,
Category,
CategoryGroup,
CategoryKind,
Transaction,
TransferLink,
TxnType,
User,
)
from . import csv_import, uebertrag_buchen
from .uebertrag_buchen import UebertragFehler
# Mehr als das ist keine Nachbesserung mehr, sondern ein Umbau – und der
# gehoert nicht hinter einen Knopf.
MAX_BUCHUNGEN = 2000
def _ziel(db: Session, user: User, regel: CategorizationRule,
txn: Transaction) -> Account | None:
"""Zielkonto einer Übertrags-Regel: fest gewählt oder aus dem Text erraten.
Erraten wird an Wortgrenzen über die Erkennungs-Texte der Konten – dieselbe
Prüfung wie beim Verbinden. „Euro" darf nicht „PayPal Europe" treffen.
"""
if regel.target_account_id:
konto = db.get(Account, regel.target_account_id)
return konto if konto and konto.owner_id == user.id else None
from .uebertraege import _kontobezeichner, _trifft_als_wort
from .csv_import import normalize_text
# Reihenfolge beachten: Gesucht wird das WORT im Text, nicht umgekehrt.
text = normalize_text(f"{txn.payee or ''} {txn.description or ''}")
treffer = [
konto_id
for konto_id, woerter in _kontobezeichner(db, user).items()
if konto_id != txn.account_id
and any(_trifft_als_wort(w, text) for w in woerter)
]
# Nur bei GENAU einem Treffer. Zwei Möglichkeiten heisst raten.
if len(treffer) != 1:
return None
return db.get(Account, treffer[0])
def wende_an(db: Session, user: User, nur_vorschau: bool = True) -> dict:
"""Regeln auf alle Buchungen ohne Kategorie anwenden.
Mit ``nur_vorschau`` wird nichts geändert – dann steht nur da, was
passieren würde.
"""
regeln = sorted(
db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id),
key=csv_import._rule_sort_key,
reverse=True,
)
if not regeln:
return {"kategorien": 0, "uebertraege": 0, "uebersprungen": [], "offen": 0}
art_je_kategorie = {
cid: kind
for cid, kind in db.query(Category.id, CategoryGroup.kind)
.join(CategoryGroup, CategoryGroup.id == Category.group_id)
.filter(Category.owner_id == user.id)
}
# Verknuepfte Buchungen sind erledigt – genau wie in der Arbeitsliste.
# Ohne diesen Ausschluss passen sie weiter auf die Uebertrags-Regel, werden
# abgelehnt („schon verbunden“) und stehen bei JEDEM Lauf erneut unter
# „uebersprungen“. Dort verdecken sie die wirklich offenen Buchungen und
# fuellen obendrein das Limit.
verknuepft = union(
select(TransferLink.from_txn_id).where(TransferLink.owner_id == user.id),
select(TransferLink.to_txn_id).where(TransferLink.owner_id == user.id),
)
offen = (
db.query(Transaction)
.filter(Transaction.owner_id == user.id,
Transaction.category_id.is_(None),
Transaction.type != TxnType.transfer,
Transaction.id.notin_(verknuepft))
.order_by(Transaction.date, Transaction.id)
.limit(MAX_BUCHUNGEN)
.all()
)
kategorien = uebertraege = 0
uebersprungen: list[dict] = []
for txn in offen:
art = (CategoryKind.income if txn.type == TxnType.income
else CategoryKind.expense)
regel = csv_import._match_rule(
regeln, txn.payee or "", txn.description or "", txn.amount_cents,
art, art_je_kategorie)
if regel is None:
continue
text = txn.payee or txn.description or "ohne Text"
if regel.is_transfer:
ziel = _ziel(db, user, regel, txn)
if ziel is None:
uebersprungen.append({
"text": text, "datum": txn.date.isoformat(),
"grund": "kein eindeutiges Zielkonto"})
continue
if nur_vorschau:
uebertraege += 1
continue
# Je Uebertrag ein eigener Rettungspunkt: Ein einfaches rollback()
# verwuerfe auch alles, was in diesem Durchlauf schon gelungen ist.
try:
with db.begin_nested():
uebertrag_buchen.aus_buchung(db, user, txn, ziel)
uebertraege += 1
except UebertragFehler as e:
uebersprungen.append({"text": text, "datum": txn.date.isoformat(),
"grund": str(e)})
elif regel.category_id:
if not nur_vorschau:
txn.category_id = regel.category_id
kategorien += 1
if not nur_vorschau:
db.commit()
return {"kategorien": kategorien, "uebertraege": uebertraege,
"uebersprungen": uebersprungen, "offen": len(offen)}
+425
View File
@@ -0,0 +1,425 @@
"""Gleichwährige Überträge zwischen eigenen Konten erkennen.
Ein Übertrag steht in zwei Auszügen: als Abgang beim einen Konto, als Zugang
beim anderen. Beide werden gebucht – das ist richtig, denn beide Salden bewegen
sich. Nur zählen sie ohne Zusammenhang als Ausgabe UND als Einnahme in der
Auswertung, obwohl kein Geld ausgegeben wurde.
Dieses Modul findet solche Paare und legt eine ``TransferLink`` an. An den
Buchungen selbst wird nichts geändert; die Verknüpfung ist eine Schicht
obendrauf. Eine falsche Verknüpfung ist damit ein Etikett, das man abzieht –
kein Eingriff, der rückgängig gemacht werden müsste.
Die Regeln sind bewusst eng. Der Vorgänger hat Zielkonten aus Texten geraten
und Beträge über Wechselkurse geschätzt; beides hat Salden verfälscht. Hier
gilt: exakt gleicher Betrag bei gleicher Währung, gleicher Tag.
Bei VERSCHIEDENEN Währungen wird der EZB-Kurs des Tages herangezogen – aber nur
als Prüfung, ob zwei Beträge zueinander passen können. Gespeichert wird nichts
davon; beide Buchungen behalten den Betrag, der in ihrem Auszug steht. Genau
das war der Unterschied zum alten Ansatz: Der hat den Gegenbetrag geschätzt und
in die Buchung geschrieben, und damit Salden verfälscht. Eine Verknüpfung kann
das nicht – sie ist nur ein Etikett.
"""
from __future__ import annotations
import re
from collections import defaultdict
from sqlalchemy.orm import Session
from ..models import (
Account,
AccountIdentifier,
AccountType,
IdentifierKind,
LinkIgnored,
Transaction,
TransferLink,
TxnType,
User,
)
from . import currency
from .csv_import import INBOX_TAGE, normalize_text
# Wörter, die eine Bewegung zwischen eigenen Konten ankündigen. Ein Treffer
# allein verknüpft noch nichts – er erlaubt nur, dass es ohne Rückfrage
# geschieht. Ohne Treffer wird vorgeschlagen.
HINWEISE = (
"übertrag", "uebertrag", "umbuchung", "einzahlung von", "auszahlung",
"transfer", "gutschrift von", "von konto", "auf konto", "eigenübertrag",
"umgetauscht", "umtausch", "exchange", "wechsel",
)
# Wie weit ein Betrag bei Währungswechsel vom EZB-Kurs abweichen darf. Banken
# nehmen einen Aufschlag; Revolut liegt werktags nahe am Referenzkurs, eine
# klassische Überweisung deutlich darüber. Enger als 2,5 % würde echte Wechsel
# verwerfen, weiter würde beliebige Beträge zusammenwürfeln.
FX_TOLERANZ = 0.025
def _trifft_als_wort(nadel: str, heu: str) -> bool:
"""Nur an Wortgrenzen suchen, nicht mitten im Wort.
Ein kurzer Kontoname als blosser Teilstring richtet Schaden an: „Euro"
steckt auch in „PayPal Europe" und „Europa-Park". Genau daran ist die alte
Transfer-Erkennung gescheitert.
"""
if not nadel:
return False
return re.search(rf"(?<![0-9a-zäöüß]){re.escape(nadel)}(?![0-9a-zäöüß])", heu) is not None
def _text(txn: Transaction) -> str:
return normalize_text(f"{txn.payee or ''} {txn.description or ''}")
def _kontobezeichner(db: Session, user: User) -> dict[int, list[str]]:
"""Je Konto die Wörter, an denen es im Buchungstext erkennbar ist:
sein Name und seine Erkennungs-Texte."""
je_konto: dict[int, list[str]] = defaultdict(list)
for konto in db.query(Account).filter(Account.owner_id == user.id):
name = normalize_text(konto.name)
if len(name) >= 4:
je_konto[konto.id].append(name)
for acc_id, kind, wert in (
db.query(AccountIdentifier.account_id, AccountIdentifier.kind, AccountIdentifier.value_norm)
.join(Account, Account.id == AccountIdentifier.account_id)
.filter(Account.owner_id == user.id)
):
if kind == IdentifierKind.text and wert and len(wert) >= 3:
je_konto[acc_id].append(normalize_text(wert))
return je_konto
def _kontotypen(db: Session, user: User) -> dict[int, AccountType]:
return dict(db.query(Account.id, Account.type).filter(Account.owner_id == user.id))
def _ist_kartenzahlung(a: Transaction, b: Transaction,
typen: dict[int, AccountType]) -> bool:
"""Geht hier eine Kreditkartenrechnung auf?
Eine Karte bekommt Geld nur aus einem Grund: weil ihre Rechnung bezahlt
wird. Das ist Hinweis genug – und noetig, denn im Buchungstext steht bei
so einer Zahlung oft weder ein Schlagwort noch der Name des Gegenkontos.
Entscheidend ist die RICHTUNG, nicht bloss, dass eine Karte beteiligt ist.
Die weitere Fassung „Kartenkonto auf einer Seite" wuerde einen
Karteneinkauf ueber 50,00 mit einer zufaelligen Gutschrift ueber 50,00 am
selben Tag verknuepfen – eine echte Ausgabe UND eine echte Einnahme fielen
still aus der Auswertung. Genau der Schaden, gegen den _hat_hinweis
gebaut wurde.
Zwei Karten untereinander bleiben aussen vor: Karten zahlen einander
nicht.
"""
for karte, quelle in ((a, b), (b, a)):
if (karte.type == TxnType.income
and typen.get(karte.account_id) == AccountType.kreditkarte
and typen.get(quelle.account_id) != AccountType.kreditkarte):
return True
return False
def _hat_hinweis(a: Transaction, b: Transaction, bezeichner: dict[int, list[str]],
typen: dict[int, AccountType] | None = None) -> bool:
"""Deutet mindestens eine der beiden Zeilen auf einen Übertrag hin?
Ohne diese Hürde würde ein zufälliger Treffer – 50.00 im Laden bezahlt, am
selben Tag 50.00 auf einem anderen Konto eingegangen – als „eindeutig"
gelten und eine echte Ausgabe samt echter Einnahme aus der Statistik
nehmen. Der Saldo bliebe richtig, die Auswertung nicht.
"""
if typen and _ist_kartenzahlung(a, b, typen):
return True
for txn, gegen in ((a, b), (b, a)):
text = _text(txn)
if any(h in text for h in HINWEISE):
return True
# Der Name oder Erkennungs-Text des ANDEREN Kontos im Text – an
# Wortgrenzen geprüft, damit sich „Euro" nicht wieder „Europe" einfängt.
for wort in bezeichner.get(gegen.account_id, ()):
if _trifft_als_wort(wort, text):
return True
return False
def _kandidaten(db: Session, user: User,
fenster: int = 0) -> dict[int, list[Transaction]]:
"""Zu jeder unverknüpften Buchung die möglichen Gegenstücke.
``fenster`` ist der erlaubte Abstand in Tagen. 0 heisst derselbe Tag – so
eng muss es bleiben, wo von selbst verknüpft wird.
Für reine VORSCHLÄGE darf es weiter sein, denn dort entscheidet der Nutzer.
Sonst klafft eine Lücke: Die Übertrags-Prüfung sucht die Gegenseite über
±INBOX_TAGE und schickt den Nutzer zum Verbinden – die Vorschlagsliste aber
kannte nur den gleichen Tag und bot dieses Paar nie an. Die Bank bucht die
Gegenseite regelmässig ein bis drei Tage später.
"""
verknuepft = set()
for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter(
TransferLink.owner_id == user.id
):
verknuepft.update((a, b))
ignoriert = {
tuple(sorted(p))
for p in db.query(LinkIgnored.a_txn_id, LinkIgnored.b_txn_id).filter(
LinkIgnored.owner_id == user.id
)
}
waehrung = dict(db.query(Account.id, Account.currency).filter(Account.owner_id == user.id))
buchungen = [
t for t in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.type.in_((TxnType.expense, TxnType.income)),
)
if t.id not in verknuepft
]
# Nach Betrag bündeln, der Tag wird danach geprüft – nur so lässt sich ein
# Fenster zulassen. Innerhalb eines Bündels nach Datum sortiert, damit die
# Suche abbrechen kann, sobald der Abstand zu gross wird.
nach_betrag: dict[int, list[Transaction]] = defaultdict(list)
for t in buchungen:
nach_betrag[t.amount_cents].append(t)
kandidaten: dict[int, list[Transaction]] = defaultdict(list)
for gruppe in nach_betrag.values():
if len(gruppe) < 2:
continue
gruppe.sort(key=lambda t: (t.date, t.id))
for i, a in enumerate(gruppe):
for b in gruppe[i + 1:]:
if (b.date - a.date).days > fenster:
break # sortiert: alles Weitere liegt noch ferner
if a.account_id == b.account_id:
continue
if a.type == b.type:
continue # eine Ausgabe, eine Einnahme
if waehrung.get(a.account_id) != waehrung.get(b.account_id):
continue # 100 CHF und 100 EUR sind nicht derselbe
# Betrag, nur dieselbe Zahl
if tuple(sorted((a.id, b.id))) in ignoriert:
continue
kandidaten[a.id].append(b)
kandidaten[b.id].append(a)
# Währungswechsel: unterschiedliche Beträge. Hier entscheidet der EZB-Kurs,
# ob die beiden zueinander passen können.
kandidaten = _ergaenze_waehrungswechsel(db, user, buchungen, waehrung,
ignoriert, kandidaten, fenster)
# Das nächstliegende Datum zuerst: Wo mehrere passen, ist der gleiche Tag
# das wahrscheinlichere Paar als der drei Tage später.
datum_je_id = {t.id: t.date for t in buchungen}
for a_id, liste in kandidaten.items():
a_datum = datum_je_id[a_id]
liste.sort(key=lambda b, d=a_datum: (abs((b.date - d).days), b.id))
return kandidaten
def _passt_ueber_kurs(db: Session, a: Transaction, b: Transaction,
von: str, nach: str) -> bool:
"""Passt ``b`` zum umgerechneten ``a``?
Fehlt der Kurs für den Tag, wird NICHT geraten – dann gibt es kein Paar.
"""
erwartet = currency.convert(db, a.amount_cents, von, nach, a.date)
if erwartet is None or erwartet <= 0:
return False
return abs(erwartet - b.amount_cents) <= erwartet * FX_TOLERANZ
def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandidaten,
fenster: int = 0):
nach_datum = sorted(buchungen, key=lambda t: (t.date, t.id))
for i, a in enumerate(nach_datum):
for b in nach_datum[i + 1:]:
if (b.date - a.date).days > fenster:
break # sortiert: alles Weitere liegt noch ferner
if a.account_id == b.account_id or a.type == b.type:
continue
va, vb = waehrung.get(a.account_id), waehrung.get(b.account_id)
if not va or not vb or va == vb:
continue # gleiche Währung ist oben schon erledigt
if a.amount_cents == b.amount_cents:
continue # zufällig gleiche Zahl, andere Währung
if tuple(sorted((a.id, b.id))) in ignoriert:
continue
# Die Umrechnung hat eine Richtung – beide prüfen, sonst hinge es
# davon ab, welche der beiden zufaellig zuerst einsortiert war.
if _passt_ueber_kurs(db, a, b, va, vb):
kandidaten[a.id].append(b)
if _passt_ueber_kurs(db, b, a, vb, va):
kandidaten[b.id].append(a)
return kandidaten
def _eindeutig(a: Transaction, moegliche: list[Transaction],
kandidaten: dict[int, list[Transaction]],
verbraucht: set[int]) -> Transaction | None:
"""Gibt es genau eine sinnvolle Zuordnung?
Zwei Fälle gelten als zwingend:
1. Genau ein Gegenstück, und dieses hat seinerseits nur diese eine
Möglichkeit.
2. Alle Gegenstücke sind untereinander austauschbar – gleiches Konto,
gleicher Betrag, gleicher Tag. Dann ist jede Zuordnung dieselbe, und
eine beliebige ist richtig. Das ist der Alltag: Revolut-Aufrundungen
sind hunderte gleiche Beträge am selben Tag zwischen denselben Konten.
"""
if not moegliche:
return None
if len(moegliche) == 1:
gegen = moegliche[0]
# Nur die noch OFFENEN Gegenstücke zählen. Zählte man alle, bliebe beim
# letzten Paar einer Reihe gleicher Beträge immer eines übrig: Sein
# Gegenstück hätte rechnerisch viele Kandidaten, obwohl alle anderen
# längst vergeben sind.
offen_dort = [x for x in kandidaten.get(gegen.id, ())
if x.id not in verbraucht and x.id != a.id]
return gegen if not offen_dort else None
konten = {b.account_id for b in moegliche}
return moegliche[0] if len(konten) == 1 else None
def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
"""Paare suchen. Eindeutige verknüpfen, den Rest als Vorschlag melden.
Mit ``nur_vorschlaege`` wird NICHTS verknüpft – dann kommt alles als
Vorschlag zurück. Das braucht jede Ansicht, die bloss fragen will („ist das
ein Übertrag?"), ohne beim Hinsehen schon etwas zu verändern.
"""
# Von selbst verknüpft wird nur am gleichen Tag. Vorschläge dürfen weiter
# greifen – sie ändern nichts, bis der Nutzer zustimmt.
kandidaten = _kandidaten(db, user, INBOX_TAGE if nur_vorschlaege else 0)
bezeichner = _kontobezeichner(db, user)
typen = _kontotypen(db, user)
nach_id = {}
for a_id, liste in kandidaten.items():
for b in liste:
nach_id[b.id] = b
for t in db.query(Transaction).filter(Transaction.id.in_(list(kandidaten.keys()) or [0])):
nach_id[t.id] = t
verbraucht: set[int] = set()
verknuepft = 0
vorschlaege: list[tuple[Transaction, Transaction]] = []
# Jedes Paar steht in ``kandidaten`` zweimal, einmal je Richtung. Was
# verknüpft wird, fällt über ``verbraucht`` heraus – ein Vorschlag aber
# nicht, und so kam dieselbe Zahlung zweimal zum Verbinden angeboten.
vorgeschlagen: set[tuple[int, int]] = set()
def schlage_vor(a: Transaction, b: Transaction) -> None:
paar = (min(a.id, b.id), max(a.id, b.id))
if paar in vorgeschlagen:
return
vorgeschlagen.add(paar)
vorschlaege.append((a, b))
for a_id in sorted(kandidaten):
if a_id in verbraucht:
continue
a = nach_id.get(a_id)
if a is None:
continue
offen = [b for b in kandidaten[a_id] if b.id not in verbraucht]
gegen = _eindeutig(a, offen, kandidaten, verbraucht)
if gegen is None:
if offen:
schlage_vor(a, offen[0])
continue
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
if a.category_id is not None or gegen.category_id is not None:
schlage_vor(a, gegen)
continue
if not _hat_hinweis(a, gegen, bezeichner, typen):
schlage_vor(a, gegen)
continue
if nur_vorschlaege:
schlage_vor(a, gegen)
continue
verbinde(db, user, a, gegen, automatisch=True)
verbraucht.update((a.id, gegen.id))
verknuepft += 1
if not nur_vorschlaege:
db.commit()
return {
"verknuepft": verknuepft,
"vorschlaege": [
{"a": _kurz(db, x), "b": _kurz(db, y)}
for x, y in vorschlaege
if x.id not in verbraucht and y.id not in verbraucht
],
}
def _kurz(db: Session, t: Transaction) -> dict:
konto = db.get(Account, t.account_id)
return {
"id": t.id, "date": t.date.isoformat(), "type": t.type.value,
"amount_cents": t.amount_cents, "currency": konto.currency if konto else "",
"account_id": t.account_id, "account_name": konto.name if konto else "",
"payee": t.payee, "description": t.description, "category_id": t.category_id,
}
def verbinde(db: Session, user: User, a: Transaction, b: Transaction,
automatisch: bool = False) -> TransferLink:
"""Verknüpfung anlegen. Abgang zuerst, damit die Richtung stimmt."""
von, nach = (a, b) if a.type == TxnType.expense else (b, a)
link = TransferLink(owner_id=user.id, from_txn_id=von.id, to_txn_id=nach.id,
automatisch=automatisch)
db.add(link)
db.flush()
return link
def linkliste(db: Session, user: User) -> dict[int, dict]:
"""Je Buchungs-ID die Gegenseite – für die Anzeige in der Buchungsliste.
Das Gegenkonto kommt mit: Die andere Buchung steht oft gar nicht in der
gerade gefilterten Liste, und „Übertrag" ohne Angabe wohin sagt nichts.
"""
links = db.query(TransferLink).filter(TransferLink.owner_id == user.id).all()
beteiligt = {i for l in links for i in (l.from_txn_id, l.to_txn_id)}
# Einmal nachschlagen statt je Verknüpfung – die Liste kann lang werden.
konto_von = dict(
db.query(Transaction.id, Transaction.account_id)
.filter(Transaction.id.in_(beteiligt or [0]))
.all()
)
ergebnis: dict[int, dict] = {}
for link in links:
for eigen, gegen, richtung in (
(link.from_txn_id, link.to_txn_id, "raus"),
(link.to_txn_id, link.from_txn_id, "rein"),
):
ergebnis[eigen] = {
"link_id": link.id, "other_id": gegen, "richtung": richtung,
"other_account_id": konto_von.get(gegen),
"automatisch": link.automatisch,
}
return ergebnis
def verknuepfte_ids(db: Session, user: User) -> set[int]:
"""Alle Buchungen, die Teil einer Verknüpfung sind.
Die Auswertung überspringt sie – so wie sie heute schon neutrale
Kategorien überspringt. Umkategorisiert wird nichts: Löst man die
Verknüpfung, zählt die Buchung mit ihrer alten Kategorie wieder mit.
"""
ids: set[int] = set()
for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter(
TransferLink.owner_id == user.id
):
ids.update((a, b))
return ids
+417
View File
@@ -0,0 +1,417 @@
"""Etwas zum Kontouebertrag machen – aus dem Eingang oder nachtraeglich.
Zwei Wege fuehren hierher: ein offener Vorschlag im Eingang und eine schon
gebuchte Ausgabe oder Einnahme. Beide muessen dieselben vier Dinge einhalten,
deshalb liegen sie hier und nicht in den Routern:
1. Die Richtung kommt aus dem Vorzeichen. Ein Abgang geht von hier weg, ein
Zugang kommt vom Gegenkonto her.
2. Der Betrag der Gegenseite ist bekannt oder geschaetzt – das entscheidet
ueber Punkt 3.
3. Ist er geschaetzt und haengt das Zielkonto selbst an einer Bank, wird
nachgefragt: Die Bank meldet den Zugang von allein, und ein geschaetzter
Betrag laesst sich beim Abgleich nicht wiedererkennen.
4. Auf dem Zielkonto entsteht ein Eingangs-Eintrag, der auf DIESELBE Buchung
zeigt. Er faengt die Gegenseite ab – die schon vorliegende, indem er sie
verbraucht, und die spaeter eintreffende, weil ``detect_rows`` gegen
gebuchte Eingangs-Eintraege prueft.
Ohne Punkt 4 stuende das Geld zweimal auf dem Zielkonto: Der Duplikatschutz
vergleicht ``import_hash`` – den hat eine Uebertrags-Buchung nicht – und
Eingangs-Eintraege, und ohne diesen legte der Uebertrag auf dem Zielkonto
keinen an.
Nicht betroffen ist das Buchungsformular: Wer dort von Hand einen Uebertrag
anlegt, waehlt beide Konten bewusst und hat keine Gegenseite, die noch kommt.
"""
from __future__ import annotations
from datetime import timedelta
from sqlalchemy.orm import Session
from ..models import (
Account,
BankAccountLink,
InboxItem,
InboxSource,
InboxStatus,
Transaction,
TransferLink,
TxnType,
User,
)
from . import buchung, csv_import, currency, eingang
from .csv_import import INBOX_TAGE
from .uebertraege import FX_TOLERANZ
class UebertragFehler(RuntimeError):
"""Fehler mit einem Text, den man dem Nutzer zeigen kann."""
def __init__(self, text: str, nachfragen: bool = False):
super().__init__(text)
# True heisst: Es ist kein Fehler, sondern eine Warnung, die sich mit
# ``trotzdem`` uebergehen laesst.
self.nachfragen = nachfragen
def ist_angebunden(db: Session, user: User, konto: Account) -> bool:
"""Holt Certain die Umsaetze dieses Kontos selbst von der Bank?
Entscheidend fuer einen Uebertrag: Bei einem angebundenen Gegenkonto
liefert die Bank die Gegenbuchung selbst.
"""
return db.query(BankAccountLink.id).filter(
BankAccountLink.owner_id == user.id,
BankAccountLink.account_id == konto.id,
).first() is not None
def _eingang_zu(db: Session, user: User, txn: Transaction) -> InboxItem | None:
"""Der Eingangs-Eintrag, aus dem diese Buchung entstand.
Er traegt, was die Bank ueber die Umrechnung gesagt hat. Ohne ihn bliebe
nur der EZB-Kurs – auch dann, wenn die Bank ihren eigenen genannt hat.
"""
return db.query(InboxItem).filter(
InboxItem.owner_id == user.id,
InboxItem.transaction_id == txn.id,
InboxItem.account_id == txn.account_id,
).first()
def _schon_verknuepft(db: Session, user: User, txn: Transaction) -> bool:
"""Haengt diese Buchung schon als Paar an ihrer Gegenseite?"""
return db.query(TransferLink.id).filter(
TransferLink.owner_id == user.id,
(TransferLink.from_txn_id == txn.id) | (TransferLink.to_txn_id == txn.id),
).first() is not None
def _gebuchte_gegenseite(db: Session, user: User, konto: Account, ziel: Account,
eigener_typ: TxnType, erwartet: int | None, datum,
exakt: bool) -> Transaction | None:
"""Steht auf dem Zielkonto schon eine passende Buchung?
Dann waere ein Uebertrag falsch: Er wuerde das Geld dort ein zweites Mal
bewegen. Richtig ist in dem Fall, die beiden zu VERKNUEPFEN – beide
Buchungen bleiben stehen und zaehlen nur nicht mehr in der Auswertung.
Ueber Waehrungen hinweg wird mit Spielraum verglichen: Der erwartete Betrag
ist dann gerechnet, der echte kommt von der Bank.
"""
if not erwartet:
return None
gegen_typ = TxnType.income if eigener_typ == TxnType.expense else TxnType.expense
spielraum = 0 if exakt else max(1, round(erwartet * FX_TOLERANZ))
return next(
(k for k in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.account_id == ziel.id,
Transaction.type == gegen_typ,
Transaction.date >= datum - timedelta(days=INBOX_TAGE),
Transaction.date <= datum + timedelta(days=INBOX_TAGE),
) if abs(k.amount_cents - erwartet) <= spielraum),
None,
)
def _schon_als_uebertrag(db: Session, user: User, konto: Account, ziel: Account,
betrag: int | None, datum, exakt: bool) -> Transaction | None:
"""Bewegt schon ein Uebertrag dasselbe Geld zwischen diesen beiden Konten?
Beide Seiten kommen oft getrennt herein – die eine von der Bank, die andere
aus der Datei. Wer beide einzeln zum Uebertrag macht, verschiebt das Geld
zweimal. Die Richtung ist dabei egal: Ein Uebertrag hat kein Vorzeichen,
nur ein Von und ein Nach.
"""
if not betrag:
return None
spielraum = 0 if exakt else max(1, round(betrag * FX_TOLERANZ))
paar = {konto.id, ziel.id}
return next(
(t for t in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.transfer,
Transaction.date >= datum - timedelta(days=INBOX_TAGE),
Transaction.date <= datum + timedelta(days=INBOX_TAGE),
) if {t.account_id, t.to_account_id} == paar
and min(abs(t.amount_cents - betrag),
abs((t.to_amount_cents or t.amount_cents) - betrag)) <= spielraum),
None,
)
def _pruefe_gegenseite(db: Session, user: User, konto: Account, ziel: Account,
eigener_typ: TxnType, erwartet: int | None, datum,
exakt: bool, trotzdem: bool) -> None:
if trotzdem:
return
# Zwischen denselben beiden Konten steht schon ein Uebertrag ueber
# denselben Betrag. Meist ist das dieselbe Bewegung, zweimal hereingekommen
# – einmal von der Bank, einmal aus der Datei –, und ein zweiter Uebertrag
# verschoebe das Geld noch einmal.
#
# Aber eben nur meist: Revolut-Aufrundungen sind reihenweise CHF 1.00
# zwischen demselben Paar in denselben Tagen, und die sind alle echt.
# Deshalb fragen statt verbieten – wer es weiss, bucht trotzdem.
doppelt = _schon_als_uebertrag(db, user, konto, ziel, erwartet, datum, exakt)
if doppelt is not None:
raise UebertragFehler(
f"Zwischen '{konto.name}' und '{ziel.name}' steht für den "
f"{doppelt.date.strftime('%d.%m.%Y')} schon ein Übertrag über "
f"denselben Betrag. Ist das dieselbe Bewegung, wäre sie damit "
f"doppelt erfasst – das Quellkonto zweimal belastet, das Ziel "
f"zweimal gutgeschrieben. Waren es wirklich zwei Zahlungen, buch "
f"trotzdem.",
nachfragen=True,
)
schon = _gebuchte_gegenseite(db, user, konto, ziel, eigener_typ, erwartet,
datum, exakt)
if schon is None:
return
# Haengt die gefundene Buchung schon an einer anderen, ist sie NICHT die
# Gegenseite dieser hier. Der Rat „verbinden" ginge dann ins Leere: Die
# Vorschlagsliste laesst vergebene Buchungen aus, es kaeme nie ein
# Vorschlag – und der Nutzer sucht etwas, das es nicht gibt.
if _schon_verknuepft(db, user, schon):
raise UebertragFehler(
f"Auf '{ziel.name}' steht zwar eine passende Buchung vom "
f"{schon.date.strftime('%d.%m.%Y')}, die ist aber schon mit einer "
f"anderen Buchung verbunden – also nicht die Gegenseite dieser "
f"hier. Verbinden geht deshalb nicht. Sieh auf '{ziel.name}' nach, "
f"ob die vorhandene Verknüpfung stimmt, oder ob der zweite Zugang "
f"dort schlicht fehlt.",
nachfragen=True,
)
raise UebertragFehler(
f"Auf '{ziel.name}' steht schon eine passende Buchung vom "
f"{schon.date.strftime('%d.%m.%Y')}. Beide Seiten sind also bereits "
f"gebucht – ein Übertrag würde das Geld dort ein zweites Mal bewegen. "
f"Richtig ist hier, die beiden zu verbinden: Sie bleiben stehen und "
f"zählen nur nicht mehr als Ausgabe und Einnahme.",
nachfragen=True,
)
def _gegenbetrag(db: Session, konto: Account, ziel: Account, betrag: int,
datum, eintrag: InboxItem | None = None,
) -> tuple[int | None, float | None, bool]:
"""Betrag auf der Zielseite, Kurs, und ob der Betrag BEKANNT ist.
Drei Stufen, absteigend nach Verlaesslichkeit:
1. Der Gegenbetrag, den die Bank selbst genannt hat – exakt.
2. Ihr Kurs. Genauer als die EZB, aber der Betrag bleibt gerechnet.
3. Der EZB-Kurs. Eine Naeherung; die Bank rechnet mit ihrem eigenen Kurs.
Nur in Stufe 1 gilt der Betrag als bekannt. Daran haengt, ob bei einem
angebundenen Zielkonto nachgefragt wird: Ein gerechneter Betrag laesst sich
beim naechsten Abruf nicht als dieselbe Bewegung wiedererkennen.
"""
if ziel.currency == konto.currency:
return betrag, None, True
if (eintrag is not None and eintrag.counter_amount_cents
and (eintrag.counter_currency or "").upper() == ziel.currency.upper()):
return abs(eintrag.counter_amount_cents), None, True
kurs = float(eintrag.fx_rate) if eintrag is not None and eintrag.fx_rate else None
umgerechnet = currency.convert(db, betrag, konto.currency, ziel.currency, datum, kurs)
return umgerechnet, kurs, False
def _pruefe(db: Session, user: User, konto: Account, ziel: Account,
exakt: bool, trotzdem: bool) -> None:
if ziel.id == konto.id:
raise UebertragFehler("Ein Übertrag braucht ein anderes Zielkonto")
if exakt or trotzdem:
return
if not ist_angebunden(db, user, ziel):
return
raise UebertragFehler(
f"Der Betrag auf '{ziel.name}' ist eine Umrechnung über den EZB-Kurs, "
f"nicht der Betrag, den die Bank meldet. Da das Konto selbst angebunden "
f"ist, kommt der Zugang von dort noch einmal – und liesse sich mit einem "
f"geschätzten Betrag nicht als derselbe erkennen. Sicherer: hier als "
f"Ausgabe buchen und beide Seiten danach verbinden lassen.",
nachfragen=True,
)
def _lege_an(db: Session, user: User, konto: Account, ziel: Account,
betrag_signiert: int, ziel_betrag: int | None, datum,
payee: str | None, beschreibung: str | None,
kurs: float | None = None) -> Transaction:
"""Die Uebertrags-Buchung, richtig herum.
Bei einem Zugang kennen wir den Betrag auf DIESEM Konto exakt – der gehoert
dann auf die Zielseite der Buchung, und der Abgang wird umgerechnet.
"""
betrag = abs(betrag_signiert)
if betrag_signiert < 0:
von, nach = konto, ziel
von_betrag, nach_betrag = betrag, ziel_betrag
else:
von, nach = ziel, konto
von_betrag, nach_betrag = (ziel_betrag if ziel_betrag is not None else betrag), betrag
txn = Transaction(
owner_id=user.id,
type=TxnType.transfer,
date=datum,
account_id=von.id,
to_account_id=nach.id,
amount_cents=von_betrag,
to_amount_cents=nach_betrag,
fx_rate=kurs,
payee=payee,
description=beschreibung,
)
buchung.setze_betraege(db, txn, von, nach)
db.add(txn)
db.flush()
return txn
def _gegenseite(db: Session, user: User, txn: Transaction, ziel: Account,
betrag_signiert: int, ziel_betrag: int, datum) -> InboxItem:
"""Den Eingangs-Eintrag auf dem Zielkonto – verbrauchen oder anlegen.
Verbraucht wird ein noch OFFENER Eintrag: Er beschreibt dieselbe Bewegung,
nur von der anderen Seite gesehen, und wuerde sonst ein zweites Mal gebucht.
"""
# Auf dem Zielkonto ist die Bewegung anders herum.
gesucht = -1 if betrag_signiert > 0 else 1
gesucht *= ziel_betrag
offen = [
(eid, edatum, ebetrag)
for eid, edatum, ebetrag in db.query(
InboxItem.id, InboxItem.date, InboxItem.amount_cents
).filter(
InboxItem.owner_id == user.id,
InboxItem.account_id == ziel.id,
InboxItem.status == InboxStatus.pending,
)
]
# Derselbe Abgleich wie beim Import – nicht eine zweite Vergleichslogik.
treffer = csv_import.finde_eingangs_treffer(offen, set(), datum, gesucht)
eintrag = db.get(InboxItem, treffer) if treffer is not None else None
if eintrag is None:
eintrag = InboxItem(
owner_id=user.id,
source=InboxSource.transfer,
account_id=ziel.id,
date=datum,
amount_cents=gesucht,
currency=ziel.currency,
payee=txn.payee,
note=txn.description,
)
db.add(eintrag)
eintrag.status = InboxStatus.booked
eintrag.transaction_id = txn.id
# Ein Uebertrag traegt keine Kategorie – auch nicht auf der Gegenseite.
eintrag.category_id = None
db.flush()
return eintrag
def aus_eingang(db: Session, user: User, eintrag: InboxItem, ziel: Account,
trotzdem: bool = False) -> Transaction:
"""Einen offenen Vorschlag als Kontouebertrag buchen."""
if eintrag.status == InboxStatus.booked or eintrag.transaction_id is not None:
raise UebertragFehler("Dieser Eintrag ist schon gebucht")
if eintrag.account_id is None:
raise UebertragFehler("Kein Konto zugeordnet")
konto = db.get(Account, eintrag.account_id)
if konto is None or konto.owner_id != user.id:
raise UebertragFehler("Kein Konto zugeordnet")
betrag = abs(eintrag.amount_cents)
ziel_betrag, kurs, exakt = _gegenbetrag(db, konto, ziel, betrag, eintrag.date, eintrag)
_pruefe(db, user, konto, ziel, exakt, trotzdem)
_pruefe_gegenseite(
db, user, konto, ziel,
TxnType.expense if eintrag.amount_cents < 0 else TxnType.income,
ziel_betrag, eintrag.date, exakt, trotzdem)
# Auch als Uebertrag gebucht bleibt es DIESELBE Zahlung der Bank. Ohne die
# Kennung an der Buchung waere sie beim naechsten Abruf wieder neu.
kennung = eingang.quell_kennung(eintrag.source, eintrag.external_id)
if kennung is not None and eingang.gebuchte_mit_kennung(db, user, [kennung]):
raise UebertragFehler("Diese Zahlung ist schon gebucht")
txn = _lege_an(db, user, konto, ziel, eintrag.amount_cents, ziel_betrag,
eintrag.date, eintrag.payee, eintrag.note, kurs)
txn.source_uid = kennung
eintrag.transaction_id = txn.id
eintrag.status = InboxStatus.booked
eintrag.category_id = None
_gegenseite(db, user, txn, ziel, eintrag.amount_cents,
ziel_betrag if ziel_betrag is not None else betrag, eintrag.date)
return txn
def aus_buchung(db: Session, user: User, txn: Transaction, ziel: Account,
trotzdem: bool = False) -> Transaction:
"""Eine schon gebuchte Ausgabe oder Einnahme zum Uebertrag machen.
Nicht ueber den PATCH-Weg des Buchungs-Routers: Der dreht die Richtung bei
einer Einnahme nicht um und legt keine Gegenseite an.
"""
if txn.type == TxnType.transfer:
raise UebertragFehler("Diese Buchung ist bereits ein Übertrag")
konto = db.get(Account, txn.account_id)
if konto is None or konto.owner_id != user.id:
raise UebertragFehler("Buchung ohne Konto")
# Ist sie schon mit ihrer Gegenseite verknuepft, sind BEIDE gebucht. Ein
# Uebertrag wuerde das Geld auf dem Zielkonto ein zweites Mal bewegen –
# und das laesst sich nicht mit "trotzdem" wegdruecken, es ist einfach
# falsch.
if _schon_verknuepft(db, user, txn):
raise UebertragFehler(
"Diese Buchung ist bereits mit ihrer Gegenseite verbunden – beide "
"Seiten sind gebucht und zählen schon jetzt in keiner Auswertung. "
"Ein Übertrag würde das Geld auf dem Zielkonto ein zweites Mal "
"bewegen. Wenn du es wirklich anders willst, löse zuerst die "
"Verknüpfung."
)
signiert = txn.amount_cents if txn.type == TxnType.income else -txn.amount_cents
# Der Eingangs-Eintrag traegt den Kurs der Bank – ohne ihn bliebe hier nur
# die EZB-Naeherung, auch wenn die Bank ihren eigenen genannt hat.
eintrag = _eingang_zu(db, user, txn)
ziel_betrag, kurs, exakt = _gegenbetrag(db, konto, ziel, abs(signiert),
txn.date, eintrag)
_pruefe(db, user, konto, ziel, exakt, trotzdem)
_pruefe_gegenseite(db, user, konto, ziel, txn.type, ziel_betrag, txn.date,
exakt, trotzdem)
# Die vorhandene Buchung umschreiben statt eine zweite anzulegen: Alles, was
# auf sie zeigt – Eingangs-Eintrag, Verknuepfungen – bleibt damit gueltig.
if signiert < 0:
von, nach = konto, ziel
txn.amount_cents, txn.to_amount_cents = abs(signiert), ziel_betrag
else:
von, nach = ziel, konto
txn.amount_cents = ziel_betrag if ziel_betrag is not None else abs(signiert)
txn.to_amount_cents = abs(signiert)
txn.type = TxnType.transfer
txn.account_id = von.id
txn.to_account_id = nach.id
txn.category_id = None
if kurs:
txn.fx_rate = kurs # der Kurs der Bank schlaegt die EZB
buchung.setze_betraege(db, txn, von, nach)
db.flush()
_gegenseite(db, user, txn, ziel, signiert,
ziel_betrag if ziel_betrag is not None else abs(signiert), txn.date)
return txn
+33
View File
@@ -0,0 +1,33 @@
"""Welche Waehrungen Certain kennt.
Eine Waehrung ist nur dann brauchbar, wenn es Kurse zu ihr gibt: Ohne Kurs
liesse sich ein Betrag zwar hinschreiben, aber nie in die Hauptwaehrung
umrechnen – er stuende in keiner Summe, in keinem Budget, in keiner
Auswertung. Frei erfundene Waehrungen anzubieten waere deshalb eine Falle.
Massgeblich ist darum die Liste der EZB: genau die Waehrungen, fuer die sie
werktaeglich einen Referenzkurs veroeffentlicht. EUR ist der Drehpunkt und
braucht keinen eigenen Kurs (implizit 1.0).
"""
# Der Drehpunkt. Steht bewusst getrennt: Fuer ihn gibt es keine Kurszeile.
PIVOT = "EUR"
# Die Waehrungen des taeglichen EZB-Referenzkurs-Feeds.
EZB_WAEHRUNGEN = {
"USD", "JPY", "BGN", "CZK", "DKK", "GBP", "HUF", "PLN", "RON", "SEK",
"CHF", "ISK", "NOK", "TRY", "AUD", "BRL", "CAD", "CNY", "HKD", "IDR",
"ILS", "INR", "KRW", "MXN", "MYR", "NZD", "PHP", "SGD", "THB", "ZAR",
}
# Alles, was irgendwo als Waehrung eingetragen werden darf.
ERLAUBT = EZB_WAEHRUNGEN | {PIVOT}
# Reihenfolge fuer Auswahlfelder: das Naheliegende zuerst, der Rest alphabetisch.
VORNE = ["EUR", "CHF", "USD", "GBP"]
def auswahl() -> list[str]:
"""Alle erlaubten Waehrungen, brauchbar sortiert."""
rest = sorted(ERLAUBT - set(VORNE))
return VORNE + rest
+31 -8
View File
@@ -34,14 +34,37 @@ def run_migrations_offline() -> None:
def run_migrations_online() -> None: def run_migrations_online() -> None:
with engine.connect() as connection: with engine.connect() as connection:
context.configure( # Fremdschlüssel für die Dauer der Migration abschalten.
connection=connection, #
target_metadata=target_metadata, # ``render_as_batch`` baut eine Tabelle um, indem es eine Kopie anlegt und
render_as_batch=True, # das Original löscht. SQLite führt bei DROP TABLE ein implizites DELETE
compare_type=True, # aus – mit aktiven Fremdschlüsseln schlägt das per ON DELETE CASCADE auf
) # abhängige Tabellen durch. Ein zusätzliches Feld an ``categories`` löschte
with context.begin_transaction(): # so sämtliche Schlagwörter und Budget-Vorlagen und setzte die Kategorie
context.run_migrations() # aller Buchungen auf NULL.
#
# Der Befehl muss VOR der Transaktion laufen, sonst ignoriert SQLite ihn.
roh = connection.connection
roh.rollback()
roh.execute("PRAGMA foreign_keys=OFF")
aktiv = roh.execute("PRAGMA foreign_keys").fetchone()[0]
if aktiv:
raise RuntimeError(
"Fremdschlüssel liessen sich nicht abschalten – Migration abgebrochen, "
"sonst droht Datenverlust beim Tabellen-Umbau."
)
try:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
finally:
roh.rollback()
roh.execute("PRAGMA foreign_keys=ON")
if context.is_offline_mode(): if context.is_offline_mode():
@@ -0,0 +1,48 @@
"""Regeln: Art (kind) je Regel + Unique inkl. kind.
Erlaubt, dasselbe Schlagwort einmal für eine Ausgabe- UND einmal für eine
Einnahme-Kategorie (bzw. als Transfer) zu verwenden. ``kind`` wird aus der
zugehörigen Kategorie bzw. ``is_transfer`` zurückgefüllt.
Revision ID: 0009
Revises: 0008
Create Date: 2026-08-20
"""
from alembic import op
import sqlalchemy as sa
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"categorization_rules",
sa.Column("kind", sa.String(length=16), nullable=False, server_default="expense"),
)
# Backfill: Transfer-Regeln -> "transfer", sonst die Art der Kategorie.
op.execute("UPDATE categorization_rules SET kind = 'transfer' WHERE is_transfer = 1")
op.execute(
"UPDATE categorization_rules SET kind = ("
" SELECT c.type FROM categories c WHERE c.id = categorization_rules.category_id"
") WHERE is_transfer = 0 AND category_id IS NOT NULL"
)
# Unique-Constraint um kind erweitern (SQLite: über batch neu aufbauen).
with op.batch_alter_table("categorization_rules") as batch:
batch.drop_constraint("uq_rule_owner_field_pattern", type_="unique")
batch.create_unique_constraint(
"uq_rule_owner_field_pattern_kind",
["owner_id", "match_field", "pattern", "kind"],
)
def downgrade() -> None:
with op.batch_alter_table("categorization_rules") as batch:
batch.drop_constraint("uq_rule_owner_field_pattern_kind", type_="unique")
batch.create_unique_constraint(
"uq_rule_owner_field_pattern",
["owner_id", "match_field", "pattern"],
)
batch.drop_column("kind")
@@ -0,0 +1,31 @@
"""Transfer-Regeln: optionales festes Zielkonto.
Erlaubt, einem Transfer-Schlagwort direkt ein Zielkonto zuzuweisen (z. B.
„REVOLUT" -> Konto Revolut). Ohne Zielkonto wird es weiterhin aus dem
Buchungstext erraten.
Revision ID: 0010
Revises: 0009
Create Date: 2026-08-20
"""
from alembic import op
import sqlalchemy as sa
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("categorization_rules") as batch:
batch.add_column(sa.Column("target_account_id", sa.Integer(), nullable=True))
batch.create_foreign_key(
"fk_rule_target_account", "accounts", ["target_account_id"], ["id"], ondelete="SET NULL"
)
def downgrade() -> None:
with op.batch_alter_table("categorization_rules") as batch:
batch.drop_constraint("fk_rule_target_account", type_="foreignkey")
batch.drop_column("target_account_id")
@@ -0,0 +1,42 @@
"""Schlagwörter: mehrere Beträge je Wort erlauben.
Bisher durfte ein Wort je Art nur einmal existieren – damit ließ sich genau ein
Betrag hinterlegen. Wer im Personalrestaurant mal 5.00 und mal 7.50 zahlt, konnte
den zweiten Preis nicht erfassen. Der Betrag gehört deshalb in den Unique-Schlüssel:
„sv = 5.00" und „sv = 7.50" sind zwei Regeln, „sv" ohne Betrag bleibt der Auffang.
Revision ID: 0011
Revises: 0010
Create Date: 2026-08-21
"""
from alembic import op
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("categorization_rules") as batch:
batch.drop_constraint("uq_rule_owner_field_pattern_kind", type_="unique")
batch.create_unique_constraint(
"uq_rule_owner_field_pattern_kind_amount",
["owner_id", "match_field", "pattern", "kind", "amount_cents"],
)
def downgrade() -> None:
# Mehrfach-Beträge je Wort auf einen zusammenführen, sonst greift der alte
# Schlüssel nicht mehr.
op.execute(
"DELETE FROM categorization_rules WHERE id NOT IN ("
" SELECT MIN(id) FROM categorization_rules"
" GROUP BY owner_id, match_field, pattern, kind)"
)
with op.batch_alter_table("categorization_rules") as batch:
batch.drop_constraint("uq_rule_owner_field_pattern_kind_amount", type_="unique")
batch.create_unique_constraint(
"uq_rule_owner_field_pattern_kind",
["owner_id", "match_field", "pattern", "kind"],
)
@@ -0,0 +1,29 @@
"""Import-Vorlagen: Zeilenfilter je Spaltenwert.
Revolut exportiert alle Pockets („Giro", „Erspartes", „Anlagekonto" …) in EINE
Datei – jedes mit eigenem Saldo-Verlauf. Ohne Filter landeten alle Zeilen auf
demselben Konto und die Pocket-Bewegungen zählten doppelt.
Revision ID: 0012
Revises: 0011
Create Date: 2026-08-21
"""
from alembic import op
import sqlalchemy as sa
revision = "0012"
down_revision = "0011"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("import_templates") as batch:
batch.add_column(sa.Column("filter_column", sa.Integer(), nullable=True))
batch.add_column(sa.Column("filter_value", sa.String(length=120), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("import_templates") as batch:
batch.drop_column("filter_value")
batch.drop_column("filter_column")
@@ -0,0 +1,28 @@
"""Kategorien: aus Auswertungen ausnehmen.
Für Umbuchungen zwischen eigenen Konten („Kontoübertrag"). Das Geld ist nicht
ausgegeben, es liegt nur woanders – solche Buchungen sollen den Kontostand
verändern, aber Budget, Dashboard und Statistik nicht verfälschen.
Revision ID: 0013
Revises: 0012
Create Date: 2026-08-21
"""
from alembic import op
import sqlalchemy as sa
revision = "0013"
down_revision = "0012"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("categories") as batch:
batch.add_column(sa.Column("excluded_from_stats", sa.Boolean(), nullable=False,
server_default=sa.text("0")))
def downgrade() -> None:
with op.batch_alter_table("categories") as batch:
batch.drop_column("excluded_from_stats")
@@ -0,0 +1,29 @@
"""Import-Verlauf merkt sich die verwendete Bank-Vorlage.
Eigene Vorlagen stehen als ``template_id`` im Verlauf, Bank-Vorlagen wie
Raiffeisen oder N26 stecken dagegen fest im Code und hinterliessen bisher
nichts. Damit die Oberflaeche beim Kontowechsel die zuletzt benutzte
Einstellung wieder vorschlagen kann, wird der Name der Bank-Vorlage
mitgeschrieben.
Revision ID: 0014
Revises: 0013
"""
import sqlalchemy as sa
from alembic import op
revision = "0014"
down_revision = "0013"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("import_batches") as batch:
batch.add_column(sa.Column("preset", sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("import_batches") as batch:
batch.drop_column("preset")
@@ -0,0 +1,77 @@
"""Eingang fuer extern erfasste Zahlungen und Geraetemarken.
Der Kontoauszug kommt erst am Monatsende; wer eine Zahlung gleich erfasst,
weiss noch, wofuer sie war. ``inbox_items`` haelt solche Zahlungen fest und
bleibt auch nach dem Buchen bestehen, damit der spaetere CSV-Import sie
wiedererkennt und nicht ein zweites Mal verbucht.
``api_tokens`` gibt Geraeten ein Zugangsmerkmal, die sich nicht anmelden
koennen (iOS-Kurzbefehle). Der Geltungsbereich ist bewusst eng: nur erfassen,
nicht lesen.
Revision ID: 0015
Revises: 0014
"""
import sqlalchemy as sa
from alembic import op
revision = "0015"
down_revision = "0014"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"api_tokens",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=80), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("scope", sa.String(length=32), nullable=False, server_default="quick_add"),
sa.Column("default_account_id", sa.Integer(), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["default_account_id"], ["accounts.id"], ondelete="SET NULL"),
)
op.create_index("ix_api_tokens_owner_id", "api_tokens", ["owner_id"])
op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"], unique=True)
op.create_table(
"inbox_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("amount_cents", sa.Integer(), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False, server_default="EUR"),
sa.Column("payee", sa.String(length=255), nullable=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("category_id", sa.Integer(), nullable=True),
sa.Column("transaction_id", sa.Integer(), nullable=True),
sa.Column("external_id", sa.String(length=255), nullable=True),
sa.Column("raw_text", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["category_id"], ["categories.id"], ondelete="SET NULL"),
# SET NULL, nicht CASCADE: Wird die Buchung geloescht, muss der
# Eingangs-Eintrag bleiben – sonst kaeme die Zahlung beim naechsten
# Auszug unbemerkt ein zweites Mal herein.
sa.ForeignKeyConstraint(["transaction_id"], ["transactions.id"], ondelete="SET NULL"),
sa.UniqueConstraint("owner_id", "source", "external_id",
name="uq_inbox_owner_source_external"),
)
op.create_index("ix_inbox_items_owner_id", "inbox_items", ["owner_id"])
op.create_index("ix_inbox_items_account_id", "inbox_items", ["account_id"])
op.create_index("ix_inbox_items_date", "inbox_items", ["date"])
def downgrade() -> None:
op.drop_table("inbox_items")
op.drop_table("api_tokens")
@@ -0,0 +1,64 @@
"""Postfaecher und Absender-Zuordnung fuer den Mail-Abruf.
Raiffeisen und Revolut melden Ueberweisungen per E-Mail. Diese Tabellen halten
fest, aus welchem Postfach abgeholt wird und welcher Absender auf welches Konto
bucht. Das Passwort liegt verschluesselt (nicht gehasht): Certain muss es dem
Mailserver vorlegen koennen.
Revision ID: 0016
Revises: 0015
"""
import sqlalchemy as sa
from alembic import op
revision = "0016"
down_revision = "0015"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"mail_sources",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=80), nullable=False, server_default=""),
sa.Column("host", sa.String(length=255), nullable=False),
sa.Column("port", sa.Integer(), nullable=False, server_default="993"),
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("password_enc", sa.Text(), nullable=False),
sa.Column("folder", sa.String(length=255), nullable=False, server_default="INBOX"),
sa.Column("done_folder", sa.String(length=255), nullable=True),
sa.Column("mark_seen", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("poll_minutes", sa.Integer(), nullable=False, server_default="15"),
sa.Column("last_poll_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_index("ix_mail_sources_owner_id", "mail_sources", ["owner_id"])
op.create_table(
"mail_senders",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=80), nullable=False, server_default=""),
sa.Column("from_contains", sa.String(length=255), nullable=True),
sa.Column("subject_contains", sa.String(length=255), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["source_id"], ["mail_sources.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="SET NULL"),
)
op.create_index("ix_mail_senders_source_id", "mail_senders", ["source_id"])
op.create_index("ix_mail_senders_owner_id", "mail_senders", ["owner_id"])
def downgrade() -> None:
op.drop_table("mail_senders")
op.drop_table("mail_sources")
@@ -0,0 +1,35 @@
"""Verschluesselungsart des Postfachs waehlbar machen.
Bisher gab es nur den Schalter ``use_ssl`` – also implizites TLS auf Port 993
oder gar nichts. STARTTLS auf Port 143 fehlte, und genau das bieten viele
Anbieter an. Statt eines zweiten Schalters gibt es jetzt ein Feld mit drei
klaren Werten.
Revision ID: 0017
Revises: 0016
"""
import sqlalchemy as sa
from alembic import op
revision = "0017"
down_revision = "0016"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("mail_sources",
sa.Column("security", sa.String(length=16), nullable=False, server_default="ssl"))
# Bestehende Postfaecher behalten ihre Einstellung.
op.execute("UPDATE mail_sources SET security = CASE WHEN use_ssl THEN 'ssl' ELSE 'none' END")
with op.batch_alter_table("mail_sources") as batch:
batch.drop_column("use_ssl")
def downgrade() -> None:
op.add_column("mail_sources",
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.true()))
op.execute("UPDATE mail_sources SET use_ssl = (security = 'ssl')")
with op.batch_alter_table("mail_sources") as batch:
batch.drop_column("security")
@@ -0,0 +1,26 @@
"""Absender-Zuordnung kann nach Waehrung unterscheiden.
Revolut verschickt fuer eine Zahlung in Euro und eine in Franken dieselbe Mail
vom selben Absender. Ohne die Waehrung als Kriterium liesse sich nicht sagen,
auf welches der beiden Revolut-Konten sie gehoert.
Revision ID: 0018
Revises: 0017
"""
import sqlalchemy as sa
from alembic import op
revision = "0018"
down_revision = "0017"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("mail_senders", sa.Column("currency", sa.String(length=3), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("mail_senders") as batch:
batch.drop_column("currency")
@@ -0,0 +1,38 @@
"""Vom Telefon abgelieferte Dateien, die noch niemand geprueft hat.
Am Telefon laesst sich ein Auszug mit hunderten Zeilen nicht sinnvoll
durchsehen. Die Datei wird deshalb nur entgegengenommen und spaeter in der
normalen Import-Ansicht geprueft – mit Vorschau, Saldo-Abgleich und der
Moeglichkeit, einzelne Zeilen abzuwaehlen.
Revision ID: 0019
Revises: 0018
"""
import sqlalchemy as sa
from alembic import op
revision = "0019"
down_revision = "0018"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"pending_uploads",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.Column("filename", sa.String(length=255), nullable=False, server_default=""),
sa.Column("content", sa.LargeBinary(), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="CASCADE"),
)
op.create_index("ix_pending_uploads_owner_id", "pending_uploads", ["owner_id"])
def downgrade() -> None:
op.drop_table("pending_uploads")
@@ -0,0 +1,62 @@
"""Verknuepfung zweier Buchungen zu einem Uebertrag.
Eine Schicht obendrauf: An den Buchungen selbst wird nichts geaendert. Sie
behalten Betrag, Konto, Kategorie, Import-Schluessel und Stapel-Zugehoerigkeit;
hier steht nur, dass sie zusammengehoeren.
Der frueher geplante Weg – beide zu einer Uebertrags-Buchung zusammenfuehren und
die Originale loeschen – haette den Duplikatschutz, die Import-Ruecknahme und die
Umkehrbarkeit gleichzeitig beschaedigt. Nichts zu loeschen laesst das gar nicht
erst entstehen.
Revision ID: 0020
Revises: 0019
"""
import sqlalchemy as sa
from alembic import op
revision = "0020"
down_revision = "0019"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"transfer_links",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("from_txn_id", sa.Integer(), nullable=False),
sa.Column("to_txn_id", sa.Integer(), nullable=False),
sa.Column("automatisch", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["from_txn_id"], ["transactions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["to_txn_id"], ["transactions.id"], ondelete="CASCADE"),
# Je Buchung hoechstens eine Verknuepfung.
sa.UniqueConstraint("from_txn_id", name="uq_link_from"),
sa.UniqueConstraint("to_txn_id", name="uq_link_to"),
)
op.create_index("ix_transfer_links_owner_id", "transfer_links", ["owner_id"])
op.create_index("ix_transfer_links_from", "transfer_links", ["from_txn_id"])
op.create_index("ix_transfer_links_to", "transfer_links", ["to_txn_id"])
op.create_table(
"link_ignored",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("a_txn_id", sa.Integer(), nullable=False),
sa.Column("b_txn_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["a_txn_id"], ["transactions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["b_txn_id"], ["transactions.id"], ondelete="CASCADE"),
sa.UniqueConstraint("a_txn_id", "b_txn_id", name="uq_ignored_pair"),
)
op.create_index("ix_link_ignored_owner_id", "link_ignored", ["owner_id"])
def downgrade() -> None:
op.drop_table("link_ignored")
op.drop_table("transfer_links")
@@ -0,0 +1,76 @@
"""Anbindung an Banken ueber PSD2 (Enable Banking).
Drei Ebenen: die bei Enable Banking registrierte Anwendung dieser Installation
(Application ID + privater Schluessel), die autorisierte Verbindung zu einer
Bank, und die Zuordnung eines dortigen Kontos zu einem Konto in Certain.
Der private Schluessel liegt verschluesselt – er ist so schuetzenswert wie ein
Passwort, denn mit ihm wird jede Anfrage signiert.
Revision ID: 0021
Revises: 0020
"""
import sqlalchemy as sa
from alembic import op
revision = "0021"
down_revision = "0020"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"bank_apps",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("application_id", sa.String(length=64), nullable=False),
sa.Column("private_key_enc", sa.Text(), nullable=False),
sa.Column("base_url", sa.String(length=255), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_index("ix_bank_apps_owner_id", "bank_apps", ["owner_id"])
op.create_table(
"bank_connections",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("aspsp_name", sa.String(length=120), nullable=False),
sa.Column("aspsp_country", sa.String(length=2), nullable=False),
sa.Column("session_id", sa.String(length=64), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("pending_state", sa.String(length=64), nullable=True),
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_index("ix_bank_connections_owner_id", "bank_connections", ["owner_id"])
op.create_index("ix_bank_connections_state", "bank_connections", ["pending_state"])
op.create_table(
"bank_account_links",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("connection_id", sa.Integer(), nullable=False),
sa.Column("remote_uid", sa.String(length=64), nullable=False),
sa.Column("iban", sa.String(length=64), nullable=True),
sa.Column("remote_name", sa.String(length=255), nullable=True),
sa.Column("currency", sa.String(length=3), nullable=False, server_default="EUR"),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["connection_id"], ["bank_connections.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="SET NULL"),
sa.UniqueConstraint("owner_id", "remote_uid", name="uq_bank_link_remote"),
)
op.create_index("ix_bank_links_owner_id", "bank_account_links", ["owner_id"])
op.create_index("ix_bank_links_connection", "bank_account_links", ["connection_id"])
def downgrade() -> None:
op.drop_table("bank_account_links")
op.drop_table("bank_connections")
op.drop_table("bank_apps")
@@ -0,0 +1,36 @@
"""Merkmale, an denen sich ein Bankkonto erkennen laesst.
Ein Revolut-Pocket hat keine eigene IBAN und oft keinen eigenen Namen. Bisher
hielt Certain nur uid, IBAN, Name und Waehrung fest – drei Pockets sahen damit
gleich aus und liessen sich nicht auseinanderhalten, geschweige denn zuordnen.
``product`` ist die Produktbezeichnung der Bank (bei Revolut unterscheidet sie
Konto und Pocket), ``other_id`` die Kennung fuer Konten ohne IBAN, und
``raw_json`` alles Uebrige, wie es kam – damit man nachsehen kann, statt zu
raten, was die Bank eigentlich geschickt hat.
Revision ID: 0022
Revises: 0021
"""
import sqlalchemy as sa
from alembic import op
revision = "0022"
down_revision = "0021"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("bank_account_links") as b:
b.add_column(sa.Column("product", sa.String(length=120), nullable=True))
b.add_column(sa.Column("other_id", sa.String(length=128), nullable=True))
b.add_column(sa.Column("raw_json", sa.Text(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("bank_account_links") as b:
b.drop_column("raw_json")
b.drop_column("other_id")
b.drop_column("product")
@@ -0,0 +1,36 @@
"""Kontostand, wie ihn die Bank fuehrt.
Certain rechnet den Saldo bisher ausschliesslich selbst aus: Anfangsbestand
plus alle Buchungen. Stimmt eine Buchung nicht, faellt das erst auf, wenn man
von Hand mit dem Auszug vergleicht – genau daran ist hier schon ein Abend
draufgegangen.
Der von der Bank gemeldete Saldo wird deshalb mitgefuehrt. Er ersetzt die
eigene Rechnung nicht, er prueft sie: Weichen beide voneinander ab, fehlt oder
doppelt eine Buchung, und man sieht es sofort.
Revision ID: 0023
Revises: 0022
"""
import sqlalchemy as sa
from alembic import op
revision = "0023"
down_revision = "0022"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("bank_account_links") as b:
b.add_column(sa.Column("balance_cents", sa.Integer(), nullable=True))
b.add_column(sa.Column("balance_type", sa.String(length=16), nullable=True))
b.add_column(sa.Column("balance_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("bank_account_links") as b:
b.drop_column("balance_at")
b.drop_column("balance_type")
b.drop_column("balance_cents")
@@ -0,0 +1,37 @@
"""Abruf je Konto und ein Takt fuer den automatischen Abruf.
``last_sync_at`` hing bisher nur an der Verbindung. Solange immer alle Konten
zusammen abgerufen wurden, ging das; sobald man ein einzelnes abruft, nicht
mehr: Der Zeitstempel schoebe das Startdatum der uebrigen Konten mit vor, und
deren Umsaetze aus dem uebersprungenen Zeitraum kaemen nie an. Der Stempel
gehoert deshalb an das Konto, das tatsaechlich abgerufen wurde.
``poll_minutes`` ist der Takt des automatischen Abrufs, wie ihn ein Postfach
schon hat. Vorgabe 0 – nichts laeuft ungefragt los, denn ein Abruf bucht.
Revision ID: 0024
Revises: 0023
"""
import sqlalchemy as sa
from alembic import op
revision = "0024"
down_revision = "0023"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("bank_account_links") as b:
b.add_column(sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True))
with op.batch_alter_table("bank_connections") as b:
b.add_column(sa.Column("poll_minutes", sa.Integer(), nullable=False,
server_default="0"))
def downgrade() -> None:
with op.batch_alter_table("bank_connections") as b:
b.drop_column("poll_minutes")
with op.batch_alter_table("bank_account_links") as b:
b.drop_column("last_sync_at")
@@ -0,0 +1,35 @@
"""Was die Bank ueber die Umrechnung mitteilt.
Bei einer Fremdwaehrungs-Zahlung nennt die Bank oft ihren echten Kurs und den
Betrag in der Ausgangswaehrung. Certain warf das weg und rechnete mit dem
EZB-Kurs – einer Naeherung, die auf den Rappen nicht stimmt.
Das faellt genau dort auf, wo es teuer ist: Bucht man einen Uebertrag ueber
zwei Waehrungen, laesst sich die spaeter von der Bank gelieferte Gegenseite mit
einem geschaetzten Betrag nicht als dieselbe wiedererkennen.
Revision ID: 0025
Revises: 0024
"""
import sqlalchemy as sa
from alembic import op
revision = "0025"
down_revision = "0024"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("inbox_items") as b:
b.add_column(sa.Column("fx_rate", sa.Numeric(18, 8), nullable=True))
b.add_column(sa.Column("counter_amount_cents", sa.Integer(), nullable=True))
b.add_column(sa.Column("counter_currency", sa.String(length=3), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("inbox_items") as b:
b.drop_column("counter_currency")
b.drop_column("counter_amount_cents")
b.drop_column("fx_rate")
@@ -0,0 +1,33 @@
"""Zu welchem Budget-Monat eine Buchung gehoert.
Bisher entschied allein das Datum. Bei einem Budget-Monat, der nicht am Ersten
beginnt, faellt das auf: Die Miete geht am 24. ab und gehoert doch zum naechsten
Monat, die Rechnung wird verspaetet bezahlt und gehoert zum letzten.
Leer heisst weiterhin "aus dem Datum" – das ist der Normalfall und bleibt es.
Deshalb genuegt eine Spalte ohne Vorbelegung: Alles Bestehende rechnet danach
genau wie vorher.
Revision ID: 0026
Revises: 0025
"""
import sqlalchemy as sa
from alembic import op
revision = "0026"
down_revision = "0025"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("transactions") as batch:
batch.add_column(sa.Column("budget_period", sa.String(length=7), nullable=True))
batch.create_index("ix_transactions_budget_period", ["budget_period"])
def downgrade() -> None:
with op.batch_alter_table("transactions") as batch:
batch.drop_index("ix_transactions_budget_period")
batch.drop_column("budget_period")
@@ -0,0 +1,70 @@
"""Die Kennung der Quelle steht an der Buchung selbst.
Bisher lag sie nur am Eingangs-Eintrag (``inbox_items.external_id``). Das hat
gereicht, solange dieser Eintrag da war – und genau das ist er nicht immer: Er
faellt weg, wenn ein Konto geloescht wird, er fehlt in aelteren Backups, und
nach einem neu verbundenen Bankkonto passt sein Praefix nicht mehr. Dann war
die Zahlung fuer den naechsten Abruf neu, obwohl sie laengst gebucht war.
Die Kennung gehoert deshalb an die Buchung. Dort ueberlebt sie alles, was mit
dem Eingang passiert, und die Eindeutigkeit haelt die Datenbank selbst durch –
nicht erst der pruefende Code davor.
Vorhandene Buchungen bekommen die Kennung nachgetragen, solange ihr
Eingangs-Eintrag noch existiert. Was jetzt schon verloren ist, bleibt verloren;
ab hier faellt nichts Neues mehr aus.
Revision ID: 0027
Revises: 0026
"""
import sqlalchemy as sa
from alembic import op
revision = "0027"
down_revision = "0026"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("transactions") as batch:
batch.add_column(sa.Column("source_uid", sa.String(length=320), nullable=True))
# Nachtragen, was der Eingang noch weiss. Der Name der Quelle kommt davor:
# Eine Nachrichten-Kennung aus dem Postfach und eine Umsatzkennung der Bank
# duerfen nie zufaellig gleich lauten.
#
# Zwei Eingangs-Eintraege koennen auf dieselbe Buchung zeigen (die beiden
# Seiten eines Uebertrags). Dann gewinnt der aeltere – ``MIN(id)``. Zwei
# verschiedene Buchungen koennen dagegen nicht dieselbe Kennung bekommen:
# (owner_id, source, external_id) ist im Eingang bereits eindeutig.
op.execute(sa.text("""
UPDATE transactions SET source_uid = (
SELECT i.source || ':' || i.external_id
FROM inbox_items i
WHERE i.transaction_id = transactions.id
AND i.owner_id = transactions.owner_id
AND i.external_id IS NOT NULL
ORDER BY i.id
LIMIT 1
)
WHERE source_uid IS NULL
"""))
# Erst jetzt eindeutig stellen – vorher stuende die Regel ueber leeren
# Spalten und der Nachtrag koennte sie nicht mehr fuellen.
#
# Ein INDEX, keine UNIQUE-Spaltenregel: Letztere ginge nur ueber einen
# Tabellen-Umbau, und der loescht auf SQLite per ON DELETE CASCADE die
# Uebertrags-Verknuepfungen und abgelehnten Vorschlaege mit. Genau davor
# steht tests/test_migrations.py. Der Index haelt dasselbe durch, ohne die
# Tabelle anzufassen.
op.create_index("uq_txn_owner_source_uid", "transactions",
["owner_id", "source_uid"], unique=True)
def downgrade() -> None:
op.drop_index("uq_txn_owner_source_uid", table_name="transactions")
with op.batch_alter_table("transactions") as batch:
batch.drop_column("source_uid")
@@ -0,0 +1,40 @@
"""Ein Konto kann sagen, dass Ueberweisungen darauf Sparen sind.
Bisher entschied das allein der Kontotyp (sparkonto/depot), und auch nur fuer
Uebertraege, die als EINE Buchung mit Zielkonto vorliegen. Ein Revolut-Pocket
faellt durch beide Raster: Es kommt ueber die Bank als ganz normales Konto
herein, und sein Uebertrag besteht aus ZWEI verknuepften Buchungen. Gespart
wurde trotzdem – im Budget stand davon nichts.
Mit dieser Spalte sagt man es dem Konto einmal, und es gilt fuer beide Formen.
Revision ID: 0028
Revises: 0027
"""
import sqlalchemy as sa
from alembic import op
revision = "0028"
down_revision = "0027"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Der Tabellen-Umbau ist hier sicher: migrations/env.py schaltet die
# Fremdschluessel fuer die Dauer der Migration ab, sonst risse das
# implizite DELETE beim DROP die Kontomerkmale, Buchungen und
# Eingangs-Eintraege mit. Siehe tests/test_migrations.py.
with op.batch_alter_table("accounts") as batch:
batch.add_column(sa.Column("savings_category_id", sa.Integer(), nullable=True))
batch.create_foreign_key(
"fk_accounts_savings_category", "categories",
["savings_category_id"], ["id"], ondelete="SET NULL",
)
def downgrade() -> None:
with op.batch_alter_table("accounts") as batch:
batch.drop_constraint("fk_accounts_savings_category", type_="foreignkey")
batch.drop_column("savings_category_id")
@@ -0,0 +1,31 @@
"""Eine Ausgabe, die kein Verlust ist.
Sparen zaehlt im Budget wie eine Ausgabe – zu Recht, denn das Geld ist diesen
Monat nicht mehr verfuegbar. Es stand danach aber in Rot da, und mehr gespart
als geplant galt als Budget-Ueberschreitung. Wer tausend zuruecklegt statt
achthundert, bekam eine Warnung fuer etwas Gutes.
Mit diesem Haken steht der Betrag neutral, und ein Ueberschreiten ist keins.
Revision ID: 0029
Revises: 0028
"""
import sqlalchemy as sa
from alembic import op
revision = "0029"
down_revision = "0028"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("categories") as batch:
batch.add_column(sa.Column("spending_is_good", sa.Boolean(), nullable=False,
server_default=sa.false()))
def downgrade() -> None:
with op.batch_alter_table("categories") as batch:
batch.drop_column("spending_is_good")
@@ -0,0 +1,52 @@
"""Abos und Vertraege: wiederkehrende Zahlungen mit bekanntem Betrag.
Netflix, Handyvertrag, Krankenkasse, Halbtax. Bisher sah man sie nur als
einzelne Buchungen im Auszug – die Frage "welche Abos habe ich eigentlich, und
was kosten die zusammen im Monat" liess sich nicht beantworten.
Revision ID: 0030
Revises: 0029
"""
import sqlalchemy as sa
from alembic import op
revision = "0030"
down_revision = "0029"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"subscriptions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("icon", sa.String(length=40), nullable=False, server_default="refresh-ccw"),
sa.Column("color", sa.String(length=9), nullable=False, server_default="#6b7480"),
sa.Column("amount_cents", sa.Integer(), nullable=False, server_default="0"),
sa.Column("currency", sa.String(length=3), nullable=False, server_default="EUR"),
sa.Column("interval_months", sa.Integer(), nullable=False, server_default="1"),
sa.Column("account_id", sa.Integer(),
sa.ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True),
sa.Column("category_id", sa.Integer(),
sa.ForeignKey("categories.id", ondelete="SET NULL"), nullable=True),
sa.Column("next_due", sa.Date(), nullable=True),
sa.Column("payee_pattern", sa.String(length=255), nullable=True),
sa.Column("savings_account_id", sa.Integer(),
sa.ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("cancelled_at", sa.Date(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_subscriptions_owner_id", "subscriptions", ["owner_id"])
def downgrade() -> None:
op.drop_index("ix_subscriptions_owner_id", table_name="subscriptions")
op.drop_table("subscriptions")
@@ -0,0 +1,36 @@
"""Ein Markenlogo je Vertrag.
Am Logo findet man den Vertrag schneller als am Namen – Netflix erkennt man
am roten N, nicht am Wort. Das Symbol aus der Auswahl bleibt als Rueckfall
fuer alles, wozu es kein Logo gibt.
Eigene Tabelle: Ein Blob an ``subscriptions`` zoege bei jeder Listenabfrage
saemtliche Bilder mit heraus.
Revision ID: 0031
Revises: 0030
"""
import sqlalchemy as sa
from alembic import op
revision = "0031"
down_revision = "0030"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"subscription_logos",
sa.Column("subscription_id", sa.Integer(),
sa.ForeignKey("subscriptions.id", ondelete="CASCADE"),
primary_key=True),
sa.Column("content", sa.LargeBinary(), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_table("subscription_logos")
@@ -0,0 +1,38 @@
"""Der Soll-Betrag kann sich aus den Vertraegen rechnen.
Bisher war er eine Zahl, die man hinschreibt. Wer fuer Streaming 80 plant,
weil 58 fest gebunden sind und 22 Luft sein sollen, musste beim naechsten
Preisaufschlag nachrechnen – und dabei aufpassen, seine 22 nicht zu verlieren.
Mit "berechnet" rechnet sich der Soll-Betrag selbst: Summe der Vertraege
dieser Kategorie plus einem Zuschlag, den man behaelt.
Revision ID: 0032
Revises: 0031
"""
import sqlalchemy as sa
from alembic import op
revision = "0032"
down_revision = "0031"
branch_labels = None
depends_on = None
TABELLEN = ("budget_template_lines", "budget_period_lines")
def upgrade() -> None:
for tabelle in TABELLEN:
with op.batch_alter_table(tabelle) as batch:
batch.add_column(sa.Column("modus", sa.String(length=16), nullable=False,
server_default="fest"))
batch.add_column(sa.Column("zuschlag_cents", sa.Integer(), nullable=False,
server_default="0"))
def downgrade() -> None:
for tabelle in TABELLEN:
with op.batch_alter_table(tabelle) as batch:
batch.drop_column("zuschlag_cents")
batch.drop_column("modus")
@@ -0,0 +1,78 @@
"""Anmeldung ueber einen fremden Dienst (OpenID Connect).
Drei Aenderungen:
``user_identities`` welches Konto bei welchem Anbieter zu welchem Nutzer
gehoert. Eigene Tabelle, weil jemand mehrere haben kann.
``oidc_states`` begonnene Anmeldeversuche, bis der Nutzer zurueckkommt.
``users`` ein Passwort-Hash, der leer sein DARF – ein reiner
SSO-Nutzer hat nie eines gesetzt.
Bewusst KEINE E-Mail-Spalte am Nutzer: Ueber die E-Mail automatisch
zusammenzufuehren waere der bequeme Weg und der gefaehrliche. Wer beim
Anbieter eine fremde Adresse eintragen kann, uebernaehme damit ein fremdes
Konto. Verknuepft wird ausdruecklich, im angemeldeten Zustand.
Revision ID: 0033
Revises: 0032
"""
import sqlalchemy as sa
from alembic import op
revision = "0033"
down_revision = "0032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"user_identities",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("provider", sa.String(length=32), nullable=False),
sa.Column("subject", sa.String(length=255), nullable=False),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
# Eine Kennung gehoert zu genau EINEM Nutzer – sonst landete derselbe
# Anmeldende mal hier und mal dort.
sa.UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),
)
op.create_index("ix_user_identities_user_id", "user_identities", ["user_id"])
op.create_table(
"oidc_states",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("state", sa.String(length=64), nullable=False, unique=True),
sa.Column("provider", sa.String(length=32), nullable=False),
sa.Column("nonce", sa.String(length=64), nullable=False),
sa.Column("code_verifier", sa.String(length=128), nullable=False),
sa.Column("redirect_uri", sa.String(length=500), nullable=False),
sa.Column("link_user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True),
sa.Column("weiter", sa.String(length=255), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_oidc_states_state", "oidc_states", ["state"])
# password_hash darf jetzt leer sein. Das geht auf SQLite nur ueber einen
# Tabellen-Umbau; migrations/env.py schaltet die Fremdschluessel dafuer ab,
# sonst risse das implizite DELETE beim DROP saemtliche Daten des Nutzers
# mit (siehe tests/test_migrations.py).
with op.batch_alter_table("users") as batch:
batch.alter_column("password_hash", existing_type=sa.String(length=255),
nullable=True)
def downgrade() -> None:
with op.batch_alter_table("users") as batch:
batch.alter_column("password_hash", existing_type=sa.String(length=255),
nullable=False)
op.drop_index("ix_oidc_states_state", table_name="oidc_states")
op.drop_table("oidc_states")
op.drop_index("ix_user_identities_user_id", table_name="user_identities")
op.drop_table("user_identities")
@@ -0,0 +1,40 @@
"""Welche Buchung zu welchem Vertrag gehoert.
Bisher wurde der Erkennungstext am Vertrag nur erfasst und lag ungenutzt. Jetzt
bekommt die Buchung ein Etikett, das sagt, welcher Vertrag sie ist.
Ein Etikett, keine Umbuchung: An der Buchung aendert sich nichts. Sie behaelt
Betrag, Konto und Kategorie – hier steht nur, dass die beiden zusammengehoeren.
Genau wie bei den Uebertrags-Verknuepfungen, und aus demselben Grund: Faellt
die Zuordnung falsch aus, zieht man ein Etikett ab und hat keine Buchung
verbogen.
Revision ID: 0034
Revises: 0033
"""
import sqlalchemy as sa
from alembic import op
revision = "0034"
down_revision = "0033"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Ohne batch_alter_table: Eine Spalte anhaengen kann SQLite direkt, und der
# Tabellen-Umbau risse ueber ON DELETE CASCADE die Uebertrags-
# Verknuepfungen und abgelehnten Vorschlaege mit (tests/test_migrations.py).
# Der Fremdschluessel laesst sich so nicht mitschreiben; SQLite prueft ihn
# fuer diese Spalte dann nicht. Zu verschmerzen: Die API prueft den Vertrag
# ohnehin, und ein verwaister Verweis waere ein Etikett ohne Wirkung, kein
# Datenschaden. Die Zuordnung raeumt ihn beim naechsten Lauf weg.
op.execute(sa.text("ALTER TABLE transactions ADD COLUMN subscription_id INTEGER"))
op.create_index("ix_transactions_subscription_id", "transactions",
["subscription_id"])
def downgrade() -> None:
op.drop_index("ix_transactions_subscription_id", table_name="transactions")
op.execute(sa.text("ALTER TABLE transactions DROP COLUMN subscription_id"))
@@ -0,0 +1,35 @@
"""Der Kreditrahmen bekommt einen eigenen Platz.
Bisher hatte niemand einen Ort fuer die Zahl, die eine Kreditkarte ausmacht:
wie viel sie ausgeben darf. Wer sie trotzdem festhalten wollte, trug sie als
Startsaldo ein – und sah fortan ein Guthaben, das es nicht gibt.
Eine Karte ist kein Konto mit Geld darauf. Abbezahlt steht sie auf null, jede
Ausgabe drueckt sie ins Minus, die Rechnung holt sie zurueck. Der Rahmen ist
eine Zusage der Bank, kein Besitz – und gehoert deshalb neben den Saldo, nicht
hinein.
Optional: Konten ohne Eintrag verhalten sich wie bisher.
Revision ID: 0035
Revises: 0034
"""
import sqlalchemy as sa
from alembic import op
revision = "0035"
down_revision = "0034"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Ohne batch_alter_table, aus demselben Grund wie in 0034: Eine Spalte
# anhaengen kann SQLite direkt, der Tabellen-Umbau risse ueber ON DELETE
# CASCADE haengende Zeilen mit (tests/test_migrations.py).
op.execute(sa.text("ALTER TABLE accounts ADD COLUMN credit_limit_cents INTEGER"))
def downgrade() -> None:
op.execute(sa.text("ALTER TABLE accounts DROP COLUMN credit_limit_cents"))
@@ -0,0 +1,36 @@
"""Geld, an das man nicht herankommt.
Eine Saeule 3a ist gesperrt – bis zur Rente, zur Selbstaendigkeit oder zur
Auswanderung. Bisher zaehlte ihr Stand in dieselbe Summe wie das Girokonto, und
die Antwort auf „wie viel habe ich" war um genau diesen Betrag zu hoch.
Das Konto behaelt seinen Saldo und bleibt Teil des Vermoegens. Nur die Summe,
die sagt, was ausgegeben werden kann, laesst es aussen vor und weist es
daneben aus.
Kein Ableiten aus dem Kontotyp: Ein frei verkaeufliches Depot und eine
gesperrte Saeule 3a kommen beide als „depot" herein.
Revision ID: 0036
Revises: 0035
"""
import sqlalchemy as sa
from alembic import op
revision = "0036"
down_revision = "0035"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Ohne batch_alter_table, wie in 0034/0035: Eine Spalte anhaengen kann
# SQLite direkt, der Tabellen-Umbau risse ueber ON DELETE CASCADE haengende
# Zeilen mit (tests/test_migrations.py).
op.execute(sa.text(
"ALTER TABLE accounts ADD COLUMN is_locked BOOLEAN NOT NULL DEFAULT 0"))
def downgrade() -> None:
op.execute(sa.text("ALTER TABLE accounts DROP COLUMN is_locked"))
@@ -0,0 +1,32 @@
"""„Sieht gleich aus, ist aber richtig so."
Die Dubletten-Liste konnte man bisher nur leeren, indem man loeschte. Drei
Aufrundungen ueber 1.00 am selben Tag sind aber echt – wer das entschieden
hat, soll nicht bei jedem Blick erneut gefragt und zum Loeschen gedraengt
werden.
Die Buchung bleibt unveraendert; das ist nur ein Haken an der Frage.
Revision ID: 0037
Revises: 0036
"""
import sqlalchemy as sa
from alembic import op
revision = "0037"
down_revision = "0036"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Ohne batch_alter_table, wie 0034-0036: Eine Spalte anhaengen kann SQLite
# direkt, der Tabellen-Umbau risse ueber ON DELETE CASCADE haengende
# Zeilen mit (tests/test_migrations.py).
op.execute(sa.text(
"ALTER TABLE transactions ADD COLUMN duplicate_ok BOOLEAN NOT NULL DEFAULT 0"))
def downgrade() -> None:
op.execute(sa.text("ALTER TABLE transactions DROP COLUMN duplicate_ok"))
@@ -0,0 +1,60 @@
"""Tags fuer Buchungen.
Die Kategorie beantwortet „wofuer war das Geld". Alles, was quer dazu liegt,
hatte bisher keinen Platz: „Urlaub Island", „steuerlich absetzbar",
„zurueckfordern". Dafuer gibt es jetzt frei vergebene Etiketten mit eigener
Farbe, mehrere je Buchung.
Zwei neue Tabellen, keine bestehende wird angefasst – ``create_table`` ist
deshalb unbedenklich. Die Warnung gegen ``batch_alter_table`` aus 0034-0037
betrifft den UMBAU vorhandener Tabellen, der ueber ON DELETE CASCADE haengende
Zeilen mitrisse (tests/test_migrations.py).
Revision ID: 0038
Revises: 0037
"""
import sqlalchemy as sa
from alembic import op
revision = "0038"
down_revision = "0037"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"tags",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(length=60), nullable=False),
sa.Column("color", sa.String(length=9), nullable=False,
server_default="#6b7480"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
)
op.create_index("ix_tags_owner_id", "tags", ["owner_id"])
op.create_table(
"transaction_tags",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("transaction_id", sa.Integer(),
sa.ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False),
sa.Column("tag_id", sa.Integer(),
sa.ForeignKey("tags.id", ondelete="CASCADE"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
)
op.create_index("ix_transaction_tags_owner_id", "transaction_tags", ["owner_id"])
op.create_index("ix_transaction_tags_transaction_id", "transaction_tags",
["transaction_id"])
op.create_index("ix_transaction_tags_tag_id", "transaction_tags", ["tag_id"])
def downgrade() -> None:
op.drop_table("transaction_tags")
op.drop_table("tags")
+6
View File
@@ -10,6 +10,11 @@ argon2-cffi==23.1.0
pyotp==2.9.0 pyotp==2.9.0
qrcode==8.0 qrcode==8.0
python-multipart==0.0.20 python-multipart==0.0.20
# Anmeldung ueber einen fremden Dienst (OpenID Connect): Discovery, JWKS und
# die Pruefung des ID-Tokens. Eine etablierte Bibliothek statt Eigenbau – ein
# selbst geschriebener Token-Pruefer funktioniert in neunzig Prozent der Faelle
# und laesst in den uebrigen zehn jemanden herein.
Authlib==1.6.5
httpx==0.28.1 httpx==0.28.1
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
# PDF/Bild-Import (Phase 5): Text aus PDFs (PyMuPDF) und OCR (Tesseract) für Scans/Bilder. # PDF/Bild-Import (Phase 5): Text aus PDFs (PyMuPDF) und OCR (Tesseract) für Scans/Bilder.
@@ -18,3 +23,4 @@ pytesseract==0.3.13
pillow==11.1.0 pillow==11.1.0
pytest==8.3.4 pytest==8.3.4
cryptography==50.0.0
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Echte Antworten des Backends als Prüfdatei für die iOS-Modelle.
Aufruf aus ``backend/``::
.venv/bin/python -m scripts.erzeuge_ios_fixture
Schreibt ``ios/Tests/Fixtures/antworten.json``. Der Swift-Test
``Serverantworten`` entschlüsselt daraus jedes Modell und schlägt fehl, sobald
ein Feldname nicht mehr passt.
**Warum es das gibt.** Die iOS-App wird gegen Modelle geschrieben, die jemand
aus den Pydantic-Schemas abgeschrieben hat. Ein vertippter Feldname fällt beim
Übersetzen nicht auf -- er fällt erst dem Nutzer auf, wenn die Liste leer
bleibt. Diese Datei ist die einzige Prüfung, die das fängt.
Nach jeder Änderung an einem Schema neu laufen lassen und das Ergebnis
mit committen. Die Datenbank ist eine Wegwerfdatenbank; es landen keine
echten Daten in der Datei.
"""
import json
import os
import tempfile
from pathlib import Path
# Muss vor dem Import von app stehen -- die Konfiguration wird beim Import gelesen.
os.environ["APP_DATA_DIR"] = tempfile.mkdtemp(prefix="cf-fixture-")
os.environ.setdefault("APP_ECB_FETCH_ON_START", "false")
os.environ.setdefault("APP_ADMIN_USERNAME", "admin")
os.environ.setdefault("APP_ADMIN_PASSWORD", "AdminPass1")
os.environ.setdefault("APP_SESSION_SECRET", "fixture-secret")
from datetime import date # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
ZIEL = Path(__file__).resolve().parents[2] / "ios" / "Tests" / "Fixtures" / "antworten.json"
def _seed(c: TestClient) -> dict:
"""Genug Daten, dass jedes Feld einmal belegt ist.
Leere Listen pruefen nichts: Ein falscher Feldname in einer Zeile faellt
nur auf, wenn es eine Zeile gibt.
"""
c.put("/api/settings/budget_start_day", params={"value": "1"})
giro = c.post("/api/accounts", json={
"name": "Girokonto", "type": "girokonto", "currency": "EUR",
"opening_balance_cents": 120000}).json()
sparen = c.post("/api/accounts", json={
"name": "Sparen", "type": "sparkonto", "currency": "CHF",
"opening_balance_cents": 500000, "is_locked": True}).json()
gruppen = c.get("/api/categories").json()
ausgabe = next(g for g in gruppen if g["kind"] == "expense")["categories"][0]
einnahme = next(g for g in gruppen if g["kind"] == "income")["categories"][0]
tag = c.post("/api/tags", json={"name": "Urlaub", "color": "#2a78d6"}).json()
abo = c.post("/api/subscriptions", json={
"name": "Streaming", "amount_cents": 1299, "currency": "EUR",
"interval_months": 12, "category_id": ausgabe["id"]}).json()
heute = str(date.today())
kauf = c.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": giro["id"],
"amount_cents": 4250, "category_id": ausgabe["id"], "payee": "Supermarkt",
"description": "Wocheneinkauf", "notes": "mit Pfand"}).json()
c.patch(f"/api/transactions/{kauf['id']}", json={"tag_ids": [tag["id"]]})
c.post("/api/transactions", json={
"type": "income", "date": heute, "account_id": giro["id"],
"amount_cents": 350000, "category_id": einnahme["id"], "payee": "Arbeitgeber"})
c.post("/api/transactions", json={
"type": "transfer", "date": heute, "account_id": giro["id"],
"to_account_id": sparen["id"], "amount_cents": 20000})
# Zwei getrennte Buchungen, von Hand zu einem Uebertrag verknuepft -- der
# zweite Mechanismus, den die App nachbauen muss.
raus = c.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": giro["id"],
"amount_cents": 15000, "payee": "Ueberweisung"}).json()
rein = c.post("/api/transactions", json={
"type": "income", "date": heute, "account_id": sparen["id"],
"amount_cents": 15000, "payee": "Gutschrift"}).json()
c.post("/api/uebertraege/verbinden",
json={"a_id": raus["id"], "b_id": rein["id"]})
c.put(f"/api/budget/template/category/{ausgabe['id']}",
params={"planned_cents": 50000})
c.post(f"/api/subscriptions/{abo['id']}/buchungen/{kauf['id']}")
# --- Arbeitsliste -----------------------------------------------------
# Ein Eingangseintrag entsteht nur ueber die Schnellerfassung; von Hand
# legt man keinen an. Also eine Marke ausstellen und damit buchen.
marke = c.post("/api/inbox/tokens",
json={"name": "Fixture", "default_account_id": giro["id"]}).json()
kopf = {"X-Certain-Token": marke["token"]}
c.post("/api/quick/booking", headers=kopf,
json={"amount": "12.90", "payee": "Baeckerei", "date": heute,
"external_id": "fixture-1"})
# Zwei gleich aussehende Buchungen: gleiches Konto, gleicher Tag,
# gleicher krummer Betrag. Genau das meldet /dubletten.
for _ in range(2):
c.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": giro["id"],
"amount_cents": 1043, "payee": "Kiosk", "category_id": ausgabe["id"]})
# Ein Abgang und ein Zugang ueber verschiedene Konten, gleicher Betrag,
# unverknuepft - daraus macht /uebertraege/vorschlaege einen Vorschlag.
# Beide Konten muessen dieselbe Waehrung haben: 75 CHF und 75 EUR sind
# nicht derselbe Betrag, nur dieselbe Zahl (siehe _kandidaten).
zweitgiro = c.post("/api/accounts", json={
"name": "Zweitkonto", "type": "girokonto", "currency": "EUR",
"opening_balance_cents": 0}).json()
c.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": giro["id"],
"amount_cents": 7500, "payee": "Dauerauftrag"})
c.post("/api/transactions", json={
"type": "income", "date": heute, "account_id": zweitgiro["id"],
"amount_cents": 7500, "payee": "Dauerauftrag"})
# --- Nebenraeume ------------------------------------------------------
# Ein Schlagwort, damit die Liste nicht leer ist. Die Markierung
# ("wird oft korrigiert") entsteht erst nach fuenf Anwendungen und laesst
# sich hier nicht erzwingen - dort bleibt die Liste leer.
c.post(f"/api/categories/{ausgabe['id']}/keywords", json={"word": "supermarkt"})
return {"kategorie": ausgabe["id"]}
def pfade(ids: dict) -> dict[str, str]:
"""Welche Antwort unter welchem Namen in die Pruefdatei kommt."""
return {
"version": "/api/version",
"ichSelbst": "/api/auth/me",
"einstellungen": "/api/settings",
"konten": "/api/accounts",
"kategoriegruppen": "/api/categories",
"schlagwoerter": "/api/tags",
"buchungen": "/api/transactions",
"uebertragsverknuepfungen": "/api/transactions/links",
"budget": "/api/budget/current",
"budgetvorlage": "/api/budget/template",
"abos": "/api/subscriptions",
"kontostaende": "/api/dashboard/balances",
"budgetfortschritt": "/api/dashboard/budget-progress",
"einnahmenAusgaben": "/api/dashboard/income-expense",
"letzteBuchungen": "/api/dashboard/recent",
"eingang": "/api/inbox",
"dubletten": "/api/transactions/dubletten",
"uebertragsvorschlaege": "/api/uebertraege/vorschlaege",
"schlagwoerter_der_kategorie": f"/api/categories/{ids['kategorie']}/keywords",
"markierteSchlagwoerter": "/api/categories/keywords/flagged",
"zweiterFaktor": "/api/auth/2fa/status",
# Bleibt leer: Eine Bankanbindung laesst sich ohne echten Anbieter nicht
# herstellen. Der Test prueft hier nur, dass sich die leere Liste lesen
# laesst - die Felder von BankConnectionOut deckt er nicht ab.
"bankverbindungen": "/api/bank/connections",
}
def main() -> int:
with TestClient(app) as c:
r = c.post("/api/auth/login",
data={"username": "admin", "password": "AdminPass1"})
if r.status_code != 200:
print("Anmeldung fehlgeschlagen:", r.text)
return 1
ids = _seed(c)
heraus = {}
for name, pfad in pfade(ids).items():
antwort = c.get(pfad)
if antwort.status_code != 200:
print(f"{pfad} antwortet {antwort.status_code}: {antwort.text[:200]}")
return 1
heraus[name] = antwort.json()
ZIEL.parent.mkdir(parents=True, exist_ok=True)
ZIEL.write_text(json.dumps(heraus, indent=1, ensure_ascii=False) + "\n",
encoding="utf-8")
print(f"{ZIEL} geschrieben, {len(heraus)} Antworten.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Prüft die Datenbank-Sicherungen auf Vollständigkeit.
Listet je Sicherung, wie viele Schlagwörter, Budget-Vorlagen und kategorisierte
Buchungen darin stecken, und nennt am Ende die NEUESTE noch vollständige. So
muss man nach einem Datenverlust nicht nach Zeitstempel raten.
Aufruf: pruefe_backups.py [muster]
"""
import glob
import os
import sqlite3
import sys
from datetime import datetime
MUSTER = sys.argv[1] if len(sys.argv) > 1 else "/var/lib/certain-finance/backups/*.db"
def zaehle(pfad: str) -> dict | None:
"""Kennzahlen einer Sicherung. None, wenn die Datei unlesbar ist."""
try:
con = sqlite3.connect(f"file:{pfad}?mode=ro", uri=True)
except sqlite3.Error:
return None
def z(sql: str):
try:
return con.execute(sql).fetchone()[0]
except sqlite3.Error:
return None # Tabelle gab es zu dem Zeitpunkt noch nicht
werte = {
"schlagwoerter": z("SELECT COUNT(*) FROM categorization_rules"),
"budget": z("SELECT COUNT(*) FROM budget_template_lines"),
"kategorisiert": z("SELECT COUNT(*) FROM transactions WHERE category_id IS NOT NULL"),
"buchungen": z("SELECT COUNT(*) FROM transactions"),
}
con.close()
return werte
def zahl(v) -> str:
return "–" if v is None else str(v)
dateien = sorted(glob.glob(MUSTER), key=os.path.getmtime)
if not dateien:
print(f"Keine Sicherungen gefunden unter: {MUSTER}")
raise SystemExit(1)
print(f"{'Sicherung':<32}{'Zeitpunkt':<18}{'Schlagw.':>9}{'Budget':>8}{'Buch.':>7}{'davon m. Kat.':>15}")
print("-" * 91)
neueste_heil = None
for f in dateien:
w = zaehle(f)
zeit = datetime.fromtimestamp(os.path.getmtime(f)).strftime("%d.%m. %H:%M")
if w is None:
print(f"{os.path.basename(f):<32}{zeit:<18} nicht lesbar")
continue
print(f"{os.path.basename(f):<32}{zeit:<18}{zahl(w['schlagwoerter']):>9}"
f"{zahl(w['budget']):>8}{zahl(w['buchungen']):>7}{zahl(w['kategorisiert']):>15}")
if (w["schlagwoerter"] or 0) > 0:
neueste_heil = (f, w, zeit)
print()
if neueste_heil:
f, w, zeit = neueste_heil
print(f"NEUESTE Sicherung mit Schlagwörtern: {os.path.basename(f)} ({zeit})")
print(f" {w['schlagwoerter']} Schlagwörter, {zahl(w['budget'])} Budget-Vorlagen, "
f"{zahl(w['kategorisiert'])} von {zahl(w['buchungen'])} Buchungen kategorisiert")
print()
print("Zurückspielen (Dienst stoppen, sichern, ersetzen, starten):")
print(" sudo systemctl stop certain-finance")
print(" sudo cp /var/lib/certain-finance/app.db /var/lib/certain-finance/app.db.kaputt")
print(f" sudo cp {f} /var/lib/certain-finance/app.db")
print(" sudo systemctl start certain-finance")
else:
print("Keine Sicherung enthält Schlagwörter.")
print("Dann waren zum Sicherungszeitpunkt noch keine angelegt – die Zuordnung")
print("lässt sich stattdessen nachträglich über die Schlagwörter neu erzeugen.")
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Findet Überträge, deren Gegenseite zusätzlich als eigene Buchung dasteht.
Ein Kontoübertrag bewegt Geld in EINER Buchung. Liefert die Bank dieselbe
Bewegung zusätzlich als Gutschrift oder Belastung, steht das Geld auf dem
betroffenen Konto zweimal. Genau das ist passiert, solange Certain beim
Anlegen eines Übertrags nicht nachgesehen hat, ob die Gegenseite schon
gebucht ist.
Das Skript ÄNDERT NICHTS. Es öffnet die Datenbank nur lesend und sagt, was
zu prüfen ist – aufräumen tust du von Hand, damit nichts Richtiges mit
weggeräumt wird.
Aufruf: pruefe_doppelte_uebertraege.py [pfad-zur-db]
"""
import sqlite3
import sys
from datetime import date, timedelta
# So viele Tage darf die Gegenbuchung abweichen. Banken buchen eine Bewegung
# oft ein bis zwei Tage nach dem Auslösen – dieselbe Spanne wie beim Import.
TAGE = 3
# Über Währungen hinweg ist der erwartete Betrag gerechnet, der echte kommt
# von der Bank. Ohne Spielraum fände man diese Fälle nie.
FX_SPIELRAUM = 0.025
DB = sys.argv[1] if len(sys.argv) > 1 else "/var/lib/certain-finance/app.db"
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
def geld(cents, waehrung=""):
return f"{cents / 100:,.2f}".replace(",", "'") + (f" {waehrung}" if waehrung else "")
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
# Welche Konten holt Certain selbst von der Bank? Daran haengt, welche der
# beiden Zeilen die falsche ist – und das muss niemand raten.
try:
angebunden = {r[0] for r in con.execute(
"SELECT account_id FROM bank_account_links WHERE account_id IS NOT NULL")}
except sqlite3.OperationalError:
angebunden = set() # aeltere Datenbank ohne Bankanbindung
verdacht = []
for u in con.execute("""
SELECT id, date, account_id, to_account_id, amount_cents, to_amount_cents,
payee, description
FROM transactions
WHERE type = 'transfer'
ORDER BY date, id
"""):
tag = date.fromisoformat(u["date"])
von, bis = tag - timedelta(days=TAGE), tag + timedelta(days=TAGE)
# Beide Seiten prüfen: Die überzählige Buchung kann auf dem Abgangs- wie
# auf dem Zugangskonto liegen, je nachdem, welche Seite umgewidmet wurde.
for konto_id, betrag, richtung in (
(u["account_id"], u["amount_cents"], "expense"),
(u["to_account_id"], u["to_amount_cents"] or u["amount_cents"], "income"),
):
if konto_id is None or not betrag:
continue
gleiche_waehrung = (konten.get(u["account_id"], {})["currency"]
== konten.get(u["to_account_id"], {})["currency"]
if u["to_account_id"] in konten and u["account_id"] in konten
else True)
spielraum = 0 if gleiche_waehrung else max(1, round(betrag * FX_SPIELRAUM))
for k in con.execute("""
SELECT id, date, amount_cents, payee, description, category_id
FROM transactions
WHERE account_id = ? AND type = ? AND date BETWEEN ? AND ? AND id != ?
""", (konto_id, richtung, von.isoformat(), bis.isoformat(), u["id"])):
if abs(k["amount_cents"] - betrag) <= spielraum:
verdacht.append((u, konto_id, betrag, k, richtung))
if not verdacht:
print("Nichts gefunden – kein Übertrag hat eine zusätzlich gebuchte Gegenseite.")
sys.exit(0)
print(f"{len(verdacht)} Stelle(n) zu prüfen.\n")
abweichung = {}
for u, konto_id, betrag, k, richtung in verdacht:
konto = konten.get(konto_id, {"name": "?", "currency": ""})
a = konten.get(u["account_id"], {"name": "?"})
b = konten.get(u["to_account_id"], {"name": "?"})
gegen_id = u["to_account_id"] if konto_id == u["account_id"] else u["account_id"]
print(f"Übertrag #{u['id']} {u['date']} {a['name']} → {b['name']} "
f"{geld(u['amount_cents'])}")
print(f" {u['payee'] or u['description'] or 'ohne Text'}")
print(f" daneben auf '{konto['name']}': Buchung #{k['id']} {k['date']} "
f"{geld(k['amount_cents'], konto['currency'])}")
print(f" {k['payee'] or k['description'] or 'ohne Text'}")
# Das Vorzeichen entscheidet, in welche Richtung das Konto danebenliegt.
vorzeichen = 1 if richtung == "income" else -1
zu = "zu hoch" if vorzeichen > 0 else "zu niedrig"
print(f" → '{konto['name']}' ist um {geld(k['amount_cents'])} {zu}")
if u["account_id"] in angebunden and u["to_account_id"] in angebunden:
print(f" → BEIDE Konten sind angebunden: Die Bank liefert beide Seiten "
f"selbst.\n Falsch ist der ÜBERTRAG #{u['id']} – löschen. Danach "
f"'{a['name']}' neu abrufen,\n dann verbindet Certain die beiden.")
elif gegen_id not in angebunden:
gegen = konten.get(gegen_id, {"name": "?"})
print(f" → '{gegen['name']}' wird NICHT abgerufen: Der Übertrag ist richtig.\n"
f" Überzählig ist Buchung #{k['id']} – die kann weg.")
else:
print(f" → Nur ein Konto ist angebunden. Sieh dir das Paar von Hand an.")
print()
abweichung[konto_id] = abweichung.get(konto_id, 0) + vorzeichen * k["amount_cents"]
print("Wenn sich der Verdacht bestätigt, liegen die Konten so daneben:")
for konto_id, cents in sorted(abweichung.items()):
konto = konten.get(konto_id, {"name": "?", "currency": ""})
richtung = "zu hoch" if cents > 0 else "zu niedrig"
print(f" {konto['name']:<28} {geld(abs(cents), konto['currency'])} {richtung}")
print("""
Lösch je Paar nur EINE der beiden Zeilen – welche, steht oben.
Im Zweifel: Kontenseite ansehen. Steht dort "Saldo stimmt mit der Bank
überein", ist das Konto in Ordnung, egal was hier steht.""")
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Vergleicht die gebuchten Daten Tag für Tag mit dem Original-Auszug.
Stimmt ein Kontostand nicht, sagt das nur DASS etwas fehlt. Dieses Skript sagt
WO: Es rechnet je Tag zusammen, was im Auszug steht und was in der Datenbank
gelandet ist, und listet die Tage auf, an denen beides auseinandergeht.
Aufruf (Spalten heissen wie in der Kopfzeile der Datei):
pruefe_gegen_auszug.py --konto 2 --datum "Datum des Abschlusses" --betrag Betrag \
--gebuehr "Gebühr" --status Status --filter "Produkt=Giro" auszug.csv [db]
"""
import argparse
import csv
import sqlite3
from collections import Counter, defaultdict
from datetime import date
NICHT_GEBUCHT = {"PENDING", "AUSSTEHEND", "REVERTED", "STORNIERT", "DECLINED",
"ABGELEHNT", "FAILED", "FEHLGESCHLAGEN", "CANCELLED"}
p = argparse.ArgumentParser()
p.add_argument("datei")
p.add_argument("db", nargs="?", default="/var/lib/certain-finance/app.db")
p.add_argument("--konto", type=int, required=True)
p.add_argument("--datum", required=True)
p.add_argument("--betrag", required=True)
p.add_argument("--gebuehr")
p.add_argument("--status")
p.add_argument("--filter", dest="filt")
p.add_argument("--trenner", default=",")
a = p.parse_args()
def zahl(s) -> int:
"""Betragstext -> Cent. Verträgt '1'234.56', '1.234,56' und leere Felder."""
s = str(s or "").strip().replace("'", "").replace(" ", "")
if not s:
return 0
if "," in s and "." in s:
s = s.replace(".", "").replace(",", ".") if s.rindex(",") > s.rindex(".") else s.replace(",", "")
elif "," in s:
s = s.replace(",", ".")
try:
return round(float(s) * 100)
except ValueError:
return 0
def tag(s) -> date | None:
s = str(s or "").strip()[:10]
for f in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y"):
try:
return date.strptime(s, f) if False else __import__("datetime").datetime.strptime(s, f).date()
except ValueError:
continue
return None
# --- Auszug einlesen ---------------------------------------------------------
fs, fv = (a.filt.split("=", 1) if a.filt else (None, None))
je_tag_datei: dict[date, int] = defaultdict(int)
zeilen_datei: dict[date, list] = defaultdict(list)
zeilen = 0
with open(a.datei, encoding="utf-8-sig", newline="") as f:
for r in csv.DictReader(f, delimiter=a.trenner):
if fs and (r.get(fs) or "").strip() != fv:
continue
if a.status and (r.get(a.status) or "").strip().upper() in NICHT_GEBUCHT:
continue
d = tag(r.get(a.datum))
if d is None:
continue
betrag = zahl(r.get(a.betrag)) - zahl(r.get(a.gebuehr)) if a.gebuehr else zahl(r.get(a.betrag))
je_tag_datei[d] += betrag
zeilen_datei[d].append(betrag)
zeilen += 1
# --- Datenbank ---------------------------------------------------------------
con = sqlite3.connect(f"file:{a.db}?mode=ro", uri=True)
je_tag_db: dict[date, int] = defaultdict(int)
zeilen_db: dict[date, list] = defaultdict(list)
for d, typ, betrag, ziel, zielbetrag, payee, besch in con.execute(
"SELECT date, type, amount_cents, to_account_id, to_amount_cents, "
"COALESCE(payee,''), COALESCE(description,'') FROM transactions "
"WHERE account_id = ? OR to_account_id = ?", (a.konto, a.konto)):
dd = tag(d)
if dd is None:
continue
if typ == "income":
wert = betrag
elif typ == "expense":
wert = -betrag
elif ziel == a.konto:
wert = zielbetrag if zielbetrag is not None else betrag
else:
wert = -betrag
je_tag_db[dd] += wert
zeilen_db[dd].append((wert, typ, (payee + " " + besch).strip()))
def geld(c):
return f"{c / 100:,.2f}".replace(",", "'")
print(f"Auszug: {a.datei} ({zeilen} gebuchte Zeilen)")
print(f"Konto {a.konto} in {a.db}\n")
print(f"Summe Auszug: {geld(sum(je_tag_datei.values())):>12}")
print(f"Summe Datenbank: {geld(sum(je_tag_db.values())):>12}")
print(f"Unterschied: {geld(sum(je_tag_db.values()) - sum(je_tag_datei.values())):>12}\n")
print("=== Monate mit Abweichung ===")
mon_d, mon_b = defaultdict(int), defaultdict(int)
for d, v in je_tag_datei.items():
mon_d[d.strftime("%Y-%m")] += v
for d, v in je_tag_db.items():
mon_b[d.strftime("%Y-%m")] += v
for m in sorted(set(mon_d) | set(mon_b)):
if mon_d[m] != mon_b[m]:
print(f" {m} Auszug {geld(mon_d[m]):>11} DB {geld(mon_b[m]):>11} "
f"Differenz {geld(mon_b[m] - mon_d[m]):>10}")
print("\n=== Tage mit Abweichung (die 25 grössten) ===")
tage = [(d, je_tag_db[d] - je_tag_datei[d]) for d in set(je_tag_datei) | set(je_tag_db)
if je_tag_db[d] != je_tag_datei[d]]
tage.sort(key=lambda t: -abs(t[1]))
for d, diff in tage[:25]:
print(f" {d} Auszug {geld(je_tag_datei[d]):>10} DB {geld(je_tag_db[d]):>10} "
f"Differenz {geld(diff):>10}")
if not tage:
print(" keine – der Auszug ist vollständig und korrekt gebucht.")
print("\n=== Welche Buchungen fehlen bzw. sind zu viel ===")
print("(Vergleich der einzelnen Betraege je Tag; der Auszug enthaelt keine Texte)")
for d, diff in tage[:10]:
aus, dbs = Counter(zeilen_datei[d]), Counter(w for w, _, _ in zeilen_db[d])
fehlt = sorted((aus - dbs).elements())
zuviel = sorted((dbs - aus).elements())
if not fehlt and not zuviel:
continue
print(f"\n {d} (Differenz {geld(diff)})")
if fehlt:
print(f" fehlt in Certain: {', '.join(geld(v) for v in fehlt)}")
if zuviel:
print(" zu viel in Certain:")
gezeigt = Counter()
for w, typ, text in sorted(zeilen_db[d], key=lambda x: x[0]):
if gezeigt[w] < Counter(zuviel)[w]:
gezeigt[w] += 1
print(f" {geld(w):>10} {typ:<9} {text[:52]}")
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Schlüsselt auf, woraus sich jeder Kontostand zusammensetzt.
Saldo = Anfangssaldo + Einnahmen - Ausgaben - Transfers raus + Transfers rein.
Stimmt ein Kontostand nicht, liegt es fast immer an einer dieser fünf Zahlen –
oder an einem doppelt eingelesenen Auszug. Beides zeigt dieses Skript.
Aufruf: pruefe_salden.py [pfad-zur-db]
"""
import sqlite3
import sys
from collections import Counter
DB = sys.argv[1] if len(sys.argv) > 1 else "/var/lib/certain-finance/app.db"
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
def eins(sql, *p):
return con.execute(sql, p).fetchone()[0] or 0
def geld(c, w=""):
return f"{c / 100:,.2f}".replace(",", "'") + (f" {w}" if w else "")
print(f"Datenbank: {DB}\n")
for a in con.execute("SELECT id, name, currency, opening_balance_cents, opening_date FROM accounts "
"WHERE is_archived = 0 ORDER BY position, id"):
ein = eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? AND type='income'", a["id"])
aus = eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? AND type='expense'", a["id"])
raus = eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? AND type='transfer'", a["id"])
rein = eins("SELECT SUM(COALESCE(to_amount_cents, amount_cents)) FROM transactions "
"WHERE to_account_id=? AND type='transfer'", a["id"])
saldo = a["opening_balance_cents"] + ein - aus - raus + rein
anz = eins("SELECT COUNT(*) FROM transactions WHERE account_id=? OR to_account_id=?", a["id"], a["id"])
spanne = con.execute("SELECT MIN(date), MAX(date) FROM transactions WHERE account_id=?", (a["id"],)).fetchone()
print(f"=== {a['name']} ({a['currency']}, id {a['id']}) ===")
stichtag = a["opening_date"]
print(f" Anfangssaldo {geld(a['opening_balance_cents']):>14}"
f" Stichtag: {stichtag or '– keiner –'}")
print(f" + Einnahmen {geld(ein):>14}")
print(f" - Ausgaben {geld(aus):>14}")
print(f" - Transfers raus {geld(raus):>14}")
print(f" + Transfers rein {geld(rein):>14}")
print(f" = SALDO {geld(saldo, a['currency']):>14}")
print(f" {anz} Buchungen, {spanne[0] or '–'} bis {spanne[1] or '–'}")
# Der Stichtag wird von der Saldo-Berechnung derzeit ignoriert: sie addiert
# ALLE Buchungen. Gemeint ist aber "an diesem Tag stand so viel drauf", also
# dürften nur spätere Buchungen zählen. Hier steht, was das ausmacht.
if stichtag:
vor = (eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? "
"AND type='income' AND date <= ?", a["id"], stichtag)
- eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? "
"AND type='expense' AND date <= ?", a["id"], stichtag)
- eins("SELECT SUM(amount_cents) FROM transactions WHERE account_id=? "
"AND type='transfer' AND date <= ?", a["id"], stichtag)
+ eins("SELECT SUM(COALESCE(to_amount_cents, amount_cents)) FROM transactions "
"WHERE to_account_id=? AND type='transfer' AND date <= ?", a["id"], stichtag))
if vor:
print(f" ACHTUNG: {geld(vor)} stammt aus Buchungen BIS zum Stichtag und wird")
print(f" trotzdem mitgezählt. Ohne sie: {geld(saldo - vor, a['currency'])}")
# Doppelte Auszüge erkennen: gleicher Dateiname mehrfach gebucht.
batches = con.execute(
"SELECT filename, status, imported_count, created_at FROM import_batches "
"WHERE account_id=? ORDER BY created_at", (a["id"],)).fetchall()
namen = Counter(b["filename"] for b in batches if b["status"] == "committed")
for b in batches:
marke = " <-- Datei mehrfach gebucht!" if b["status"] == "committed" and namen[b["filename"]] > 1 else ""
print(f" [{b['status']:<9}] {b['imported_count']:>4} aus {b['filename'][:44]}{marke}")
# Buchungen, die sich in Datum+Betrag+Empfänger gleichen.
dop = con.execute(
"SELECT date, amount_cents, COALESCE(payee,''), COUNT(*) n FROM transactions "
"WHERE account_id=? GROUP BY 1,2,3 HAVING n > 1 ORDER BY n DESC LIMIT 5", (a["id"],)).fetchall()
if dop:
summe = eins("SELECT SUM(amount_cents * (n-1)) FROM (SELECT amount_cents, COUNT(*) n "
"FROM transactions WHERE account_id=? GROUP BY date, amount_cents, COALESCE(payee,'') "
"HAVING n > 1)", a["id"])
print(f" Mehrfach vorhanden: {len(dop)} Kombinationen, Summe der Überzähligen {geld(summe)}")
for d in dop:
print(f" {d[0]} {geld(d[1]):>10} {d[2][:34]:<34} x{d[3]}")
print()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Zeigt, warum Schlagwörter in der Oberfläche fehlen könnten.
Die Liste einer Kategorie zeigt nur Regeln mit ``source='manual'``, die einer
Kategorie zugeordnet und keine Transfer-Regel sind. Automatisch gelernte Regeln
tauchen dort bewusst nicht auf. Dieses Skript schlüsselt den Bestand danach auf.
Aufruf: pruefe_schlagwoerter.py [pfad/zur/app.db]
"""
import sqlite3
import sys
from collections import Counter
DB = sys.argv[1] if len(sys.argv) > 1 else "/var/lib/certain-finance/app.db"
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
def spalten(tabelle: str) -> set[str]:
return {r["name"] for r in con.execute(f"PRAGMA table_info({tabelle})")}
vorhanden = spalten("categorization_rules")
print(f"Datenbank: {DB}")
print(f"Spalten in categorization_rules: {', '.join(sorted(vorhanden))}\n")
gesamt = con.execute("SELECT COUNT(*) FROM categorization_rules").fetchone()[0]
print(f"Regeln insgesamt: {gesamt}")
for feld in ("source", "kind", "is_transfer", "match_field"):
if feld not in vorhanden:
print(f" {feld:<12} – Spalte fehlt")
continue
verteilung = Counter(
r[0] for r in con.execute(f"SELECT {feld} FROM categorization_rules")
)
print(f" {feld:<12} {dict(verteilung)}")
ohne_kat = con.execute(
"SELECT COUNT(*) FROM categorization_rules WHERE category_id IS NULL"
).fetchone()[0]
verwaist = con.execute(
"SELECT COUNT(*) FROM categorization_rules r WHERE r.category_id IS NOT NULL"
" AND NOT EXISTS (SELECT 1 FROM categories c WHERE c.id = r.category_id)"
).fetchone()[0]
print(f" ohne Kategorie: {ohne_kat}")
print(f" Kategorie zeigt ins Leere: {verwaist} <-- solche erscheinen nirgends")
# Genau die Bedingung, die die Oberfläche für die Kategorie-Liste nutzt.
sichtbar = con.execute(
"SELECT COUNT(*) FROM categorization_rules r"
" JOIN categories c ON c.id = r.category_id"
" WHERE r.source = 'manual' AND r.is_transfer = 0"
).fetchone()[0]
print(f"\nIn der Kategorie-Liste sichtbar: {sichtbar} von {gesamt}")
print("\nBeispiele (die ersten 12):")
print(f" {'Muster':<26}{'source':<10}{'kind':<10}{'transfer':<9}{'Kategorie'}")
for r in con.execute(
"SELECT r.pattern, r.source, r.kind, r.is_transfer, c.name AS kat"
" FROM categorization_rules r LEFT JOIN categories c ON c.id = r.category_id"
" ORDER BY r.id LIMIT 12"
):
print(f" {r['pattern'][:24]:<26}{str(r['source']):<10}{str(r['kind']):<10}"
f"{str(r['is_transfer']):<9}{r['kat'] or '— fehlt —'}")
con.close()
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Zeigt, welches Konto an welches überträgt – und wo dabei etwas fehlt.
Ein falscher Kontostand kommt selten von den Ausgaben, fast immer von den
Überträgen: falsche Richtung, fehlendes Zielkonto oder ein Währungswechsel
ohne Zielbetrag (dann wird der Fremdbetrag 1:1 gutgeschrieben).
Aufruf: pruefe_transfers.py [pfad-zur-db]
"""
import sqlite3
import sys
DB = sys.argv[1] if len(sys.argv) > 1 else "/var/lib/certain-finance/app.db"
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
def name(i):
return konten[i]["name"] if i in konten else f"?{i}"
def waehrung(i):
return konten[i]["currency"] if i in konten else "?"
def geld(c):
return f"{(c or 0) / 100:,.2f}".replace(",", "'")
print(f"Datenbank: {DB}\n")
print("=== Wer überträgt an wen ===")
print(f"{'von':<24}{'nach':<24}{'Anz':>5}{'Summe raus':>14}{'Summe rein':>14}")
print("-" * 81)
for r in con.execute(
"SELECT account_id a, to_account_id b, COUNT(*) n, SUM(amount_cents) raus, "
"SUM(COALESCE(to_amount_cents, amount_cents)) rein FROM transactions "
"WHERE type='transfer' AND to_account_id IS NOT NULL GROUP BY a, b ORDER BY raus DESC"
):
print(f"{name(r['a'])[:23]:<24}{name(r['b'])[:23]:<24}{r['n']:>5}"
f"{geld(r['raus']) + ' ' + waehrung(r['a']):>14}"
f"{geld(r['rein']) + ' ' + waehrung(r['b']):>14}")
# Beispieltexte: sie verraten, welches Schlagwort oder welcher Erkennungs-
# text den Übertrag ausgelöst hat.
for b in con.execute(
"SELECT COALESCE(payee,'') p, COALESCE(description,'') d, COUNT(*) n "
"FROM transactions WHERE type='transfer' AND account_id=? AND to_account_id=? "
"GROUP BY p, d ORDER BY n DESC LIMIT 3", (r["a"], r["b"])):
print(f" {b['n']:>3}x {(b['p'] + ' | ' + b['d']).strip(' |')[:66]}")
offen = con.execute(
"SELECT COUNT(*) n, SUM(amount_cents) s FROM transactions "
"WHERE type='transfer' AND to_account_id IS NULL").fetchone()
print(f"\nÜberträge OHNE Zielkonto: {offen['n']}, Summe {geld(offen['s'])}"
" <-- gehen vom Quellkonto ab und kommen nirgends an")
print("\n=== Währungswechsel ohne Zielbetrag ===")
schief = con.execute(
"SELECT t.id, t.date, t.amount_cents, COALESCE(t.payee,'') p, a.name qn, a.currency qw, "
"z.name zn, z.currency zw FROM transactions t "
"JOIN accounts a ON a.id = t.account_id JOIN accounts z ON z.id = t.to_account_id "
"WHERE t.type='transfer' AND a.currency <> z.currency AND t.to_amount_cents IS NULL "
"ORDER BY t.amount_cents DESC").fetchall()
if not schief:
print(" keine – gut.")
else:
summe = sum(r["amount_cents"] for r in schief)
print(f" {len(schief)} Stück, Summe {geld(summe)}"
" <-- der Betrag wird ohne Umrechnung gutgeschrieben")
for r in schief[:12]:
print(f" {r['date']} {geld(r['amount_cents']):>10} {r['qw']} -> {r['zw']} "
f"{r['qn'][:16]:<16} -> {r['zn'][:16]:<16} {r['p'][:26]}")
print("\n=== Die 12 grössten Überträge ===")
for r in con.execute(
"SELECT t.date, t.amount_cents, t.to_amount_cents, COALESCE(t.payee,'') p, "
"t.account_id a, t.to_account_id b FROM transactions t WHERE t.type='transfer' "
"ORDER BY t.amount_cents DESC LIMIT 12"
):
ziel = name(r["b"]) if r["b"] else "– kein Ziel –"
print(f" {r['date']} {geld(r['amount_cents']):>10} {waehrung(r['a'])} "
f"{name(r['a'])[:18]:<18} -> {ziel[:18]:<18} {r['p'][:30]}")
print("\n=== Erkennungstexte der Konten ===")
print("Ein kurzer Text trifft zu viel: er wird als Teilstring gesucht.")
for r in con.execute(
"SELECT a.name, i.kind, i.value FROM account_identifiers i "
"JOIN accounts a ON a.id = i.account_id ORDER BY LENGTH(i.value), a.name"):
warn = " <-- sehr kurz, trifft leicht Fremdes" if len(r["value"]) < 8 else ""
print(f" {r['name'][:24]:<26}{r['kind']:<10}{r['value']}{warn}")
print("\n=== Transfer-Schlagwörter ===")
for r in con.execute(
"SELECT r.pattern, r.target_account_id t FROM categorization_rules r "
"WHERE r.is_transfer = 1 ORDER BY LENGTH(r.pattern)"):
ziel = name(r["t"]) if r["t"] else "– Ziel wird geraten –"
warn = " <-- sehr kurz" if len(r["pattern"]) < 8 else ""
print(f" {r['pattern'][:34]:<36}-> {ziel}{warn}")
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Findet Bank-Umsätze, die eine schon vorhandene Buchung wiederholen.
Zwei Fälle:
Bank gegen Datei Ein Abruf über einen längeren Zeitraum greift in Monate
zurück, die schon per CSV eingelesen sind.
Bank gegen Bank Dieselbe Bank ist zweimal verbunden (Enable Banking führt
sie je Land getrennt und vergibt eigene Kennungen). Jeder
Zugang meldet dieselbe Wirklichkeit – die Fassung eines
Zugangs bleibt stehen.
Ein Abruf über einen längeren Zeitraum greift in Monate zurück, die schon per
CSV eingelesen sind. Der Duplikatschutz erkennt das nicht: Sein Schlüssel
enthält den Empfängertext, und der lautet bei der Bank anders als im Auszug.
Die Zahlung steht danach zweimal da.
Gesucht wird deshalb unscharf: gleiches Konto, gleiche Richtung, gleicher
Betrag, Datum ±3 Tage. Jede vorhandene Buchung kann dabei nur EINE
Bank-Buchung erklären – sonst verschluckt eine Kaffeezahlung alle anderen
desselben Betrags.
Weggeräumt wird immer die von der BANK, nie deine eigene: Deine trägt die
Kategorie, die du vergeben hast. Sie wird nicht gelöscht, sondern verworfen –
so holt sie kein späterer Abruf wieder herein.
Das Skript ändert ohne ``--wirklich`` nichts. Mit ``--wirklich`` legt es zuerst
eine Kopie der Datenbank an.
Aufruf: raeume_doppelte_bankumsaetze.py [pfad-zur-db] [--wirklich]
"""
import sqlite3
import sys
from datetime import date, datetime, timedelta
TAGE = 3
argumente = [a for a in sys.argv[1:] if not a.startswith("--")]
WIRKLICH = "--wirklich" in sys.argv
DB = argumente[0] if argumente else "/var/lib/certain-finance/app.db"
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
def geld(cents):
return f"{cents / 100:,.2f}".replace(",", "'")
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
# Welche Buchungen kamen von der Bank – und ueber WELCHES Bankkonto? Die
# Kennung steht vorn in external_id ("<uid>:<referenz>"). Sie unterscheidet
# zwei Zugaenge zu derselben Bank, die Enable Banking je Land getrennt fuehrt.
von_bank = {}
uid_von = {}
for r in con.execute("""
SELECT id, transaction_id, external_id FROM inbox_items
WHERE source = 'bank' AND transaction_id IS NOT NULL
"""):
von_bank[r["transaction_id"]] = r["id"]
uid_von[r["transaction_id"]] = (r["external_id"] or "").split(":")[0]
alle = list(con.execute("""
SELECT id, account_id, type, date, amount_cents, payee, description, category_id
FROM transactions
WHERE type != 'transfer'
ORDER BY date, id
"""))
# Eigene Buchungen je (Konto, Richtung, Betrag) – daraus wird gleich geschöpft.
eigene: dict[tuple, list] = {}
for t in alle:
if t["id"] in von_bank:
continue
eigene.setdefault((t["account_id"], t["type"], t["amount_cents"]), []).append(t)
verbraucht: set[int] = set()
funde = []
for t in alle:
if t["id"] not in von_bank:
continue
schluessel = (t["account_id"], t["type"], t["amount_cents"])
tag = date.fromisoformat(t["date"])
passend = None
for kandidat in eigene.get(schluessel, []):
if kandidat["id"] in verbraucht:
continue
if abs((date.fromisoformat(kandidat["date"]) - tag).days) <= TAGE:
passend = kandidat
break
if passend is not None:
verbraucht.add(passend["id"])
funde.append((t, passend))
# --- Zweiter Durchgang: dieselbe Zahlung ueber ZWEI Bankzugaenge ----------
# Wer dieselbe Bank zweimal verbindet (etwa Revolut ueber DE und ueber LT),
# bekommt fuer dieselbe Zahlung zwei verschiedene Kennungen – der
# Duplikatschutz kann sie deshalb nicht als dieselbe erkennen. Jeder Zugang
# meldet dieselbe Wirklichkeit, also bleibt die Fassung EINES Zugangs stehen.
nach_gruppe: dict[tuple, list] = {}
for t in alle:
if t["id"] not in von_bank:
continue
nach_gruppe.setdefault(
(t["account_id"], t["type"], t["date"], t["amount_cents"]), []).append(t)
for gruppe in nach_gruppe.values():
je_uid: dict[str, list] = {}
for t in gruppe:
je_uid.setdefault(uid_von.get(t["id"], ""), []).append(t)
if len(je_uid) < 2:
continue # nur ein Zugang – nichts zu entscheiden
# Der Zugang mit den meisten Zeilen beschreibt die Wirklichkeit am
# vollstaendigsten; bei Gleichstand der aeltere (kleinere Buchungs-ID).
behalten = max(je_uid.values(), key=lambda zeilen: (len(zeilen), -zeilen[0]["id"]))
for zeilen in je_uid.values():
if zeilen is behalten:
continue
for t in zeilen:
funde.append((t, behalten[0]))
if not funde:
print("Nichts gefunden – kein Bank-Umsatz wiederholt eine vorhandene Buchung.")
sys.exit(0)
print(f"{len(funde)} Bank-Umsätze wiederholen eine vorhandene Buchung.\n")
je_konto: dict[int, int] = {}
for bank, eigen in funde:
konto = konten.get(bank["account_id"], {"name": "?", "currency": ""})
zeichen = -1 if bank["type"] == "expense" else 1
print(f" {bank['date']} {konto['name']:<22} {geld(bank['amount_cents']):>10} "
f"{(bank['payee'] or bank['description'] or '')[:38]}")
print(f" wiederholt #{eigen['id']} vom {eigen['date']}: "
f"{(eigen['payee'] or eigen['description'] or '')[:38]}")
je_konto[bank["account_id"]] = je_konto.get(bank["account_id"], 0) + zeichen * bank["amount_cents"]
print("\nDie Konten liegen dadurch daneben:")
for konto_id, cents in sorted(je_konto.items()):
konto = konten.get(konto_id, {"name": "?", "currency": ""})
richtung = "zu niedrig" if cents < 0 else "zu hoch"
print(f" {konto['name']:<24} {geld(abs(cents))} {konto['currency']} {richtung}")
if not WIRKLICH:
print(f"\nNur angesehen, nichts geändert. Zum Ausführen:\n"
f" {sys.argv[0]} {DB} --wirklich")
sys.exit(0)
kopie = f"{DB}.vor-aufraeumen-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
with sqlite3.connect(kopie) as ziel:
con.backup(ziel)
print(f"\nKopie der Datenbank: {kopie}")
for bank, _ in funde:
# Verwerfen statt loeschen: Der Eintrag bleibt als Sperre stehen, damit
# kein spaeterer Abruf denselben Umsatz noch einmal hereinholt.
con.execute("""UPDATE inbox_items SET status = 'discarded', transaction_id = NULL
WHERE id = ?""", (von_bank[bank["id"]],))
con.execute("DELETE FROM transactions WHERE id = ?", (bank["id"],))
con.commit()
print(f"{len(funde)} doppelte Bank-Buchungen entfernt.\n"
"Sieh dir jetzt die Kontenseite an – dort muss bei den angebundenen\n"
"Konten 'Saldo stimmt mit der Bank überein' stehen.")
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Macht Überträge rückgängig, die nie welche waren.
Zwei Fehlerbilder werden gesucht:
1. OHNE BELEG – ein zu kurzer Erkennungs-Text hat mitten in einem Wort
angeschlagen („Euro" in „PayPal Europe"). Solche Zeilen sind gewöhnliche
Ausgaben/Einnahmen; als Übertrag schreiben sie dem Zielkonto Geld gut, das
nie geflossen ist.
2. SPIEGEL-PAARE – dieselbe Bewegung steht in beiden Auszügen (Revolut führt
eine Aufrundung im Giro- UND im Pocket-Konto). Wurden beide Zeilen gebucht,
existiert der Übertrag doppelt, einmal je Richtung.
Ohne ``--anwenden`` wird nur berichtet, nichts geändert.
Aufruf:
raeume_uebertraege.py [db] nur berichten
raeume_uebertraege.py --anwenden [db] Fall 1 bereinigen
raeume_uebertraege.py --anwenden --behalte "2>15" [db]
zusätzlich Spiegel-Paare:
Richtung Konto 2 -> Konto 15 behalten
"""
import datetime
import re
import shutil
import sqlite3
import sys
argv = [a for a in sys.argv[1:]]
ANWENDEN = "--anwenden" in argv
BEHALTE: set[tuple[int, int]] = set()
while "--behalte" in argv:
i = argv.index("--behalte")
a, b = argv[i + 1].split(">")
BEHALTE.add((int(a), int(b)))
del argv[i:i + 2]
rest = [a for a in argv if not a.startswith("--")]
DB = rest[0] if rest else "/var/lib/certain-finance/app.db"
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
def name(i):
return konten[i]["name"] if i in konten else f"?{i}"
def tag(s: str) -> datetime.date:
return datetime.date.fromisoformat(s[:10])
def geld(c):
return f"{(c or 0) / 100:,.2f}".replace(",", "'")
def als_wort(nadel: str, heu: str) -> bool:
if not nadel:
return False
return re.search(rf"(?<![0-9a-zäöüß]){re.escape(nadel)}(?![0-9a-zäöüß])", heu) is not None
# Erkennungstexte und Transfer-Schlagwörter einsammeln.
idents: dict[int, list[str]] = {}
for r in con.execute("SELECT account_id, value FROM account_identifiers"):
idents.setdefault(r["account_id"], []).append((r["value"] or "").strip().lower())
regeln = [(r["pattern"].lower(), r["target_account_id"]) for r in con.execute(
"SELECT pattern, target_account_id FROM categorization_rules WHERE is_transfer = 1")]
def hat_beleg(t) -> bool:
"""Spricht ausser dem alten Teilstring-Treffer irgendetwas für einen Übertrag?"""
text = f"{(t['payee'] or '').lower()} {(t['description'] or '').lower()}"
for konto in (t["account_id"], t["to_account_id"]):
for val in idents.get(konto, []):
if als_wort(val, text):
return True
for muster, ziel in regeln:
if muster and muster in text and (ziel is None or ziel in (t["account_id"], t["to_account_id"])):
return True
return False
transfers = con.execute(
"SELECT t.id, t.date, t.account_id, t.to_account_id, t.amount_cents, t.to_amount_cents, "
"t.payee, t.description, t.import_batch_id, b.account_id AS batch_account "
"FROM transactions t LEFT JOIN import_batches b ON b.id = t.import_batch_id "
"WHERE t.type='transfer' AND t.to_account_id IS NOT NULL").fetchall()
print(f"Datenbank: {DB}\n{len(transfers)} Überträge geprüft\n")
# --- Fall 1: ohne Beleg -----------------------------------------------------
ohne = [t for t in transfers if not hat_beleg(t)]
print(f"=== 1. Überträge ohne Beleg: {len(ohne)} ===")
if ohne:
je_paar: dict[tuple[int, int], list] = {}
for t in ohne:
je_paar.setdefault((t["account_id"], t["to_account_id"]), []).append(t)
for (a, b), liste in sorted(je_paar.items(), key=lambda kv: -len(kv[1])):
summe = sum(t["amount_cents"] for t in liste)
print(f" {name(a)[:22]:<24}-> {name(b)[:22]:<24}{len(liste):>4}x {geld(summe):>10}")
for t in liste[:2]:
print(f" {t['date']} {((t['payee'] or '') + ' ' + (t['description'] or '')).strip()[:60]}")
else:
print(" keine")
# --- Fall 2: Spiegel-Paare ---------------------------------------------------
nach_id = {t["id"]: t for t in transfers}
frei = set(nach_id)
paare: dict[tuple[int, int], list[tuple[int, int]]] = {}
nach_paar: dict[tuple[int, int], list] = {}
for t in transfers:
nach_paar.setdefault((t["account_id"], t["to_account_id"]), []).append(t)
for (a, b), liste in nach_paar.items():
gegen = nach_paar.get((b, a), [])
if not gegen or a > b:
continue
offen = [g for g in gegen]
for t in liste:
if t["id"] not in frei:
continue
for g in offen:
if g["id"] not in frei:
continue
if abs(t["amount_cents"]) != abs(g["amount_cents"]):
continue
if abs((tag(t["date"]) - tag(g["date"])).days) > 2:
continue
frei.discard(t["id"]); frei.discard(g["id"])
paare.setdefault((a, b), []).append((t["id"], g["id"]))
break
print(f"\n=== 2. Spiegel-Paare (dieselbe Bewegung aus beiden Auszügen) ===")
if not paare:
print(" keine")
for (a, b), liste in paare.items():
summe = sum(abs(nach_id[x]["amount_cents"]) for x, _ in liste)
print(f" {name(a)} <-> {name(b)}: {len(liste)} Paare, {geld(summe)}")
print(f" Nur EINE Richtung ist richtig. Wähle sie mit:")
print(f" --behalte \"{a}>{b}\" (behält {name(a)} -> {name(b)})")
print(f" --behalte \"{b}>{a}\" (behält {name(b)} -> {name(a)})")
# --- Anwenden ---------------------------------------------------------------
if not ANWENDEN:
print("\nNichts geändert (Probelauf). Mit --anwenden ausführen.")
raise SystemExit(0)
sicherung = DB + ".vor-aufraeumen"
shutil.copy(DB, sicherung)
print(f"\nSicherung: {sicherung}")
zurueck = 0
for t in ohne:
# Aus welchem Auszug stammt die Zeile? Diese Seite behält die Buchung.
quelle = t["batch_account"]
if quelle == t["account_id"] or quelle is None:
con.execute("UPDATE transactions SET type='expense', to_account_id=NULL, "
"to_amount_cents=NULL WHERE id=?", (t["id"],))
else:
betrag = t["to_amount_cents"] if t["to_amount_cents"] is not None else t["amount_cents"]
con.execute("UPDATE transactions SET type='income', account_id=?, amount_cents=?, "
"to_account_id=NULL, to_amount_cents=NULL WHERE id=?",
(quelle, betrag, t["id"]))
zurueck += 1
print(f"{zurueck} Überträge in Ausgabe/Einnahme zurückverwandelt")
geloescht = 0
for (a, b), liste in paare.items():
if (a, b) in BEHALTE:
weg = [y for _, y in liste]
elif (b, a) in BEHALTE:
weg = [x for x, _ in liste]
else:
print(f" {name(a)} <-> {name(b)}: übersprungen (keine Richtung gewählt)")
continue
con.executemany("DELETE FROM transactions WHERE id=?", [(i,) for i in weg])
geloescht += len(weg)
print(f"{geloescht} doppelte Überträge gelöscht")
con.commit()
print("Fertig. Dienst neu starten: sudo systemctl restart certain-finance")
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Räumt Überträge auf, deren Gegenseite zusätzlich als Buchung dasteht.
Welche der beiden Zeilen falsch ist, hängt daran, ob Certain das Gegenkonto
selbst abholt – und das steht in der Datenbank:
Beide Konten angebunden Die Bank liefert beide Seiten. Der ÜBERTRAG ist
falsch; er wird in die Buchung zurückverwandelt,
aus der er entstand. Beide Seiten stehen danach
wieder da und lassen sich verbinden.
Gegenkonto nicht abgerufen Der Übertrag ist richtig. Die zusätzliche
Buchung ist überzählig und wird gelöscht.
Ohne ``--wirklich`` wird nur gezeigt, was passieren würde. Mit ``--wirklich``
legt das Skript ZUERST eine Kopie der Datenbank an und sagt, wo sie liegt.
Aufruf: repariere_doppelte_uebertraege.py [pfad-zur-db] [--wirklich]
"""
import sqlite3
import sys
from datetime import date, datetime, timedelta
TAGE = 3
FX_SPIELRAUM = 0.025
argumente = [a for a in sys.argv[1:] if not a.startswith("--")]
WIRKLICH = "--wirklich" in sys.argv
DB = argumente[0] if argumente else "/var/lib/certain-finance/app.db"
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
def geld(cents):
return f"{cents / 100:,.2f}".replace(",", "'")
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
try:
angebunden = {r[0] for r in con.execute(
"SELECT account_id FROM bank_account_links WHERE account_id IS NOT NULL")}
except sqlite3.OperationalError:
angebunden = set()
plan, offen = [], []
# Je Uebertrag hoechstens EIN Eingriff. Findet sich auf beiden Seiten eine
# Dublette, waeren "zurueckverwandeln" und "Buchung loeschen" widerspruechlich –
# dann lieber melden als raten.
behandelt = set()
for u in con.execute("""
SELECT id, date, account_id, to_account_id, amount_cents, to_amount_cents
FROM transactions WHERE type = 'transfer' ORDER BY date, id
"""):
tag = date.fromisoformat(u["date"])
gleiche_waehrung = (konten[u["account_id"]]["currency"]
== konten[u["to_account_id"]]["currency"]
if u["account_id"] in konten and u["to_account_id"] in konten
else True)
for konto_id, betrag, richtung in (
(u["account_id"], u["amount_cents"], "expense"),
(u["to_account_id"], u["to_amount_cents"] or u["amount_cents"], "income"),
):
if konto_id is None or not betrag:
continue
spielraum = 0 if gleiche_waehrung else max(1, round(betrag * FX_SPIELRAUM))
for k in con.execute("""
SELECT id, date, amount_cents, payee FROM transactions
WHERE account_id = ? AND type = ? AND date BETWEEN ? AND ? AND id != ?
""", (konto_id, richtung, (tag - timedelta(days=TAGE)).isoformat(),
(tag + timedelta(days=TAGE)).isoformat(), u["id"])):
if abs(k["amount_cents"] - betrag) > spielraum:
continue
if u["id"] in behandelt:
offen.append(f"Übertrag #{u['id']}: passende Buchungen auf BEIDEN "
f"Seiten – bitte selbst ansehen")
continue
behandelt.add(u["id"])
a, b = konten.get(u["account_id"]), konten.get(u["to_account_id"])
kopf = (f"Übertrag #{u['id']} {u['date']} {a['name']} → {b['name']} "
f"{geld(u['amount_cents'])}\n"
f" daneben: Buchung #{k['id']} auf '{konten[konto_id]['name']}' "
f"{geld(k['amount_cents'])}")
if u["account_id"] in angebunden and u["to_account_id"] in angebunden:
# Der Uebertrag entstand aus einer echten Buchung. Welche das
# war, sagt der Eingangs-Eintrag, der auf ihn zeigt.
quelle = con.execute("""
SELECT id, account_id, amount_cents FROM inbox_items
WHERE transaction_id = ? AND source != 'transfer' LIMIT 1
""", (u["id"],)).fetchone()
if quelle is None:
offen.append(kopf + "\n → von Hand angelegt, ich kann nicht "
"wissen, wie er vorher aussah")
continue
if konten[quelle["account_id"]]["currency"] != a["currency"]:
offen.append(kopf + "\n → über zwei Währungen, der "
"Ursprungsbetrag ist nicht mehr eindeutig")
continue
plan.append(("zurueck", u, k, quelle, kopf))
elif (u["to_account_id"] if konto_id == u["account_id"]
else u["account_id"]) not in angebunden:
plan.append(("loeschen", u, k, None, kopf))
else:
offen.append(kopf + "\n → nur ein Konto angebunden, bitte selbst ansehen")
if not plan and not offen:
print("Nichts zu tun – kein Übertrag hat eine zusätzlich gebuchte Gegenseite.")
sys.exit(0)
for art, u, k, quelle, kopf in plan:
print(kopf)
if art == "zurueck":
typ = "Ausgabe" if quelle["amount_cents"] < 0 else "Einnahme"
print(f" → beide Konten angebunden: Übertrag #{u['id']} wird wieder zur "
f"{typ} auf '{konten[quelle['account_id']]['name']}'\n")
else:
print(f" → Gegenkonto wird nicht abgerufen: Buchung #{k['id']} wird "
f"gelöscht\n")
for text in offen:
print(text + "\n")
if not WIRKLICH:
print(f"Nur angesehen, nichts geändert. Zum Ausführen:\n"
f" {sys.argv[0]} {DB} --wirklich")
sys.exit(0)
kopie = f"{DB}.vor-reparatur-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
with sqlite3.connect(kopie) as ziel:
con.backup(ziel) # nimmt auch das WAL mit, anders als ein Dateikopie
print(f"Kopie der Datenbank: {kopie}\n")
for art, u, k, quelle, _ in plan:
if art == "zurueck":
typ = "expense" if quelle["amount_cents"] < 0 else "income"
con.execute("""
UPDATE transactions
SET type = ?, account_id = ?, amount_cents = ?,
to_account_id = NULL, to_amount_cents = NULL, fx_rate = NULL
WHERE id = ?
""", (typ, quelle["account_id"], abs(quelle["amount_cents"]), u["id"]))
# Die Gegenseite, die Certain fuer den Uebertrag angelegt hatte, ist
# damit gegenstandslos.
con.execute("DELETE FROM inbox_items WHERE transaction_id = ? AND source = 'transfer'",
(u["id"],))
print(f"Übertrag #{u['id']} zurückverwandelt.")
else:
# Eingangs-Eintrag verwerfen statt loeschen: Sonst brächte ihn der
# nächste Abruf oder Auszug wieder herein.
con.execute("""UPDATE inbox_items SET status = 'discarded', transaction_id = NULL
WHERE transaction_id = ?""", (k["id"],))
con.execute("DELETE FROM transactions WHERE id = ?", (k["id"],))
print(f"Buchung #{k['id']} gelöscht.")
con.commit()
print("\nFertig. Sieh dir jetzt die Kontenseite an – dort muss bei den "
"angebundenen Konten\n'Saldo stimmt mit der Bank überein' stehen.")
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Teilt bestehende Überträge in zwei gewöhnliche Buchungen auf.
Certain verbindet Konten nicht mehr selbst. Damit die Salden trotzdem exakt so
bleiben wie bisher, wird jeder Übertrag zu genau dem, was in den Auszügen steht:
einer Ausgabe beim Quellkonto und einer Einnahme beim Zielkonto. Beide bekommen
die neutrale Kategorie, zählen also nicht in die Statistik.
Ohne ``--anwenden`` wird nur berichtet. Vor dem Schreiben legt das Skript eine
Kopie der Datenbank an.
Aufruf: teile_uebertraege.py [--anwenden] [db]
"""
import shutil
import sqlite3
import sys
argv = sys.argv[1:]
ANWENDEN = "--anwenden" in argv
rest = [x for x in argv if not x.startswith("--")]
DB = rest[0] if rest else "/var/lib/certain-finance/app.db"
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
def geld(c):
return f"{(c or 0) / 100:,.2f}".replace(",", "'")
def neutrale_kategorie(owner_id: int) -> int | None:
"""Die Kategorie, die aus Auswertungen herausfällt („Kontoübertrag")."""
r = con.execute(
"SELECT id FROM categories WHERE owner_id = ? AND excluded_from_stats = 1 "
"ORDER BY (name LIKE '%bertrag%') DESC, id LIMIT 1", (owner_id,)).fetchone()
return r["id"] if r else None
def eur_cents(betrag: int, waehrung: str, tag: str, ersatz: int) -> int:
"""Betrag in EUR – für die Statistik. Ohne Kurs bleibt der bisherige Wert."""
if waehrung.upper() == "EUR":
return betrag
r = con.execute(
"SELECT rate_per_eur FROM exchange_rates WHERE currency = ? AND date <= ? "
"ORDER BY date DESC LIMIT 1", (waehrung.upper(), tag)).fetchone()
if not r or not r["rate_per_eur"]:
return ersatz
return round(betrag / float(r["rate_per_eur"]))
uebertraege = con.execute(
"SELECT * FROM transactions WHERE type = 'transfer' AND to_account_id IS NOT NULL"
).fetchall()
print(f"Datenbank: {DB}\n{len(uebertraege)} Überträge gefunden\n")
if not uebertraege:
raise SystemExit(0)
je_paar: dict[tuple[int, int], list] = {}
for t in uebertraege:
je_paar.setdefault((t["account_id"], t["to_account_id"]), []).append(t)
print(f"{'von':<24}{'nach':<24}{'Anz':>5}{'Summe':>13}")
print("-" * 66)
for (a, b), liste in sorted(je_paar.items(), key=lambda kv: -len(kv[1])):
na = konten[a]["name"] if a in konten else f"?{a}"
nb = konten[b]["name"] if b in konten else f"?{b}"
print(f"{na[:23]:<24}{nb[:23]:<24}{len(liste):>5}"
f"{geld(sum(x['amount_cents'] for x in liste)):>13}")
fehlende = {t["owner_id"] for t in uebertraege if neutrale_kategorie(t["owner_id"]) is None}
if fehlende:
print("\nACHTUNG: Es gibt keine Kategorie mit „nicht in Auswertungen einrechnen“.")
print("Lege eine an (z. B. „Kontoübertrag“) – sonst bleiben die Buchungen ohne Kategorie")
print("und tauchen in der Statistik auf.")
if not ANWENDEN:
print("\nNichts geändert (Probelauf). Mit --anwenden ausführen.")
raise SystemExit(0)
sicherung = DB + ".vor-aufteilen"
shutil.copy(DB, sicherung)
print(f"\nSicherung: {sicherung}")
neu = 0
for t in uebertraege:
kat = neutrale_kategorie(t["owner_id"])
ziel = konten.get(t["to_account_id"])
gutschrift = t["to_amount_cents"] if t["to_amount_cents"] is not None else t["amount_cents"]
# Abgang bleibt beim Quellkonto – als Ausgabe.
con.execute(
"UPDATE transactions SET type='expense', to_account_id=NULL, to_amount_cents=NULL, "
"category_id=COALESCE(category_id, ?) WHERE id = ?", (kat, t["id"]))
# Zugang wird eine eigene Einnahme beim Zielkonto.
if ziel is not None:
con.execute(
"INSERT INTO transactions (owner_id, type, date, time, account_id, amount_cents, "
"amount_eur_cents, category_id, payee, description, notes, import_batch_id, "
"created_at, updated_at) VALUES (?, 'income', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(t["owner_id"], t["date"], t["time"], ziel["id"], gutschrift,
eur_cents(gutschrift, ziel["currency"], t["date"], t["amount_eur_cents"]),
kat, t["payee"], t["description"], t["notes"], t["import_batch_id"],
t["created_at"], t["updated_at"]))
neu += 1
con.commit()
print(f"{len(uebertraege)} Überträge zu Ausgaben gemacht, {neu} Einnahmen ergänzt")
print("Die Salden bleiben unverändert. Dienst neu starten:")
print(" sudo systemctl restart certain-finance")
@@ -0,0 +1,48 @@
From: noreply@ebanking.raiffeisen.ch
To: kundin@example.com
Date: Fri, 31 Jul 2026 00:59:29 +0200
Message-Id: <raiffeisen-muster-1@example.invalid>
Subject: =?utf-8?Q?Raiffeisen_E-Banking_-_=C3=84nderung_Zahlungsstatus?=
Mime-Version: 1.0
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=utf-8
Sehr geehrte Kundin, sehr geehrter Kunde
Sie haben im Raiffeisen E-Banking die E-Mail-Benachrichtigung bei =C3=84nd=
erungen von Zahlungsstatus abonniert. Gerne informieren wir Sie =C3=BCber =
folgende Ver=C3=A4nderung:
Auf Ihrem YoungMember Privatkonto "YoungMember Privatkonto" hat der Status=
eines Zahlungsauftrages ge=C3=A4ndert.
Details :
Konto: YoungMember Privatkonto "YoungMember Privatkonto"
Beg=C3=BCnstigter: Sunrise GmbH / Yallo
Betrag: 14.90 CHF
Ausf=C3=BChrungsdatum: 31.07.2026
Neuer Status: Verbucht
Freundliche Gr=C3=BCsse
Ihr Raiffeisen E-Banking Team
Hinweis: Auftr=C3=A4ge via E-Mail werden aus Sicherheitsgr=C3=BCnden nicht=
entgegengenommen. Bitte benutzen Sie die entsprechende Funktion im Raiffe=
isen E-Banking.
*****************************************************
This e-mail may contain confidential material. It is intended only for the=
person or entity which it is addressed to. In case you should not be supp=
osed to get this e-mail we ask you to delete it without taking notice of i=
ts content. Any views or opinions expressed in this e-mail are those of th=
e sender and do not necessarily coincide with those of The Swiss Raiffeise=
n Group. Therefore this e-mail does not represent a binding agreement nor =
an offer to deal. E-Mail transmission can be insecure and can contain erro=
rs. Information could be intercepted, corrupted, lost, destroyed, incomple=
te or may contain viruses. Neither The Swiss Raiffeisen Group nor the send=
er can accept any liability for any kind of damage as the result of viruse=
s or transmission errors.
*****************************************************
+161
View File
@@ -0,0 +1,161 @@
From: Revolut <no-reply@revolut.com>
To: kundin@example.com
Date: Sat, 01 Aug 2026 10:41:51 +0000
Message-Id: <revolut-muster-1@example.invalid>
Subject: =?utf-8?Q?Du_hast_324_=E2=82=AC_an_Alex_Muster_gesendet?=
Mime-Version: 1.0
Content-Type: text/html; charset=utf-8
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<body style="word-spacing: normal; width: 100%;">
<div class="body-container " style>
<div class="content-wrapper" style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:auto;text-align:center;">
<div class="logo" style="padding-left: 0; padding-right: 0; margin: 0px auto; max-width: NaNpx; box-sizing: border-box;padding-top:12px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:auto;text-align:left;">
<div class="mj-column-per-100 mj-outlook-group-fix logo-logo" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:middle;padding:auto;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="left" class="logo-image" style="font-size:0px;padding:auto;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:NaNInfinity;">
<a href="https://click.revolut.com/f/a/sgS5c05Ip1cirTh3JwuFVA~~/AAD4-xA~/s9Nuf7b9WqSl9q-IEwPuNVc-FR9aXYkDhH-nZakn_0Imp98HIzqviy2AZTfeIjBsWZoeoRl_dX904l-fMzrrgAmj9VAhnpezLAyehPOI6wXZTiU7gnvPUDu03wwtwGxm" target="_blank" style="font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; text-decoration: none; font-size: inherit; color: #6FA0FF;">
<img src="https://assets.revolut.com/email/logo/v2/Revolut-black-background.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;max-height:32px;width:100%;font-size:13px;" width="Infinity" height="auto">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div style="margin:0px auto;max-width:NaNpx;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:auto;text-align:left;">
<div class="mj-column-per-100 mj-outlook-group-fix title" style="padding-left: 0; padding-right: 0; font-size: 0px; text-align: left; direction: ltr; display: inline-block; vertical-align: top; width: 100%; box-sizing: border-box;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:auto;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="left" class="title-title" style="font-size:0px;padding:auto;word-break:break-word;">
<div style="font-family:Inter, -apple-system, Roboto, Arial, Noto Sans Arabic, Noto Sans Hebrew, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"><h1 style="margin: 0; padding-top: 24px; padding-bottom: 8px; font-family: Aeonik Pro, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-size: 32px; font-weight: 500; color: #191C1F; line-height: 1.125;">Deine Überweisung war erfolgreich</h1></div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="text content-block" style="padding-left: 0; padding-right: 0; margin: 0px auto; max-width: NaNpx; box-sizing: border-box;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:auto;text-align:left;">
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:auto;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="left" class="text-compat " style="font-size: 0px; padding: auto; word-break: break-word; padding-top: 16px; padding-bottom: 16px;">
<div style="font-family:Inter, -apple-system, Roboto, Arial, Noto Sans Arabic, Noto Sans Hebrew, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"><div class="text-container" style="color: #191C1F; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; font-size: 16px;"><p style="margin: 0; padding: 0; color: #191C1F; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; font-size: 16px; padding-top: 8px; padding-bottom: 8px;">Hallo Alex,<br><br>Du hast 324 € an Alex Muster gesendet. Die Details findest du unten:</p><div class="blockquote" style="margin: 0; color: #000000; border-radius: 16px; font-size: 16px; line-height: 1.375; padding: 8px 16px; background-color: #F4F5F6;"><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">An</strong><br>Alex Muster<br><br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Gesendet </strong><br>324 € <br><br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Verwendungszweck</strong><br>Gesendet von Revolut <br><br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Am </strong><br>Samstag, 1 August, 12:41<br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;"><br></strong><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Erwartete Ankunft</strong><br>Innerhalb weniger Sekunden</div><p style="margin: 0; padding: 0; color: #191C1F; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; font-size: 16px; padding-top: 8px; padding-bottom: 8px;"><br></td></tr></table></body></html>
+384
View File
@@ -0,0 +1,384 @@
"""Welche Buchung gehoert zu welchem Vertrag.
Die Zuordnung ist ein ETIKETT: An der Buchung aendert sich nichts. Die Tests
pruefen deshalb vor allem, was gleich bleiben MUSS – und wo das Programm
lieber fragt, statt zu raten.
"""
import secrets
from datetime import date, timedelta
def _konto(admin, name=None, currency="EUR"):
r = admin.post("/api/accounts", json={
"name": name or ("Konto " + secrets.token_hex(3)), "type": "girokonto",
"currency": currency, "opening_balance_cents": 0})
assert r.status_code == 201, r.text
return r.json()
def _abo(admin, **kw):
body = {"name": "Netflix " + secrets.token_hex(3), "amount_cents": 1990,
"currency": "EUR", "interval_months": 1}
body.update(kw)
r = admin.post("/api/subscriptions", json=body)
assert r.status_code == 201, r.text
return r.json()
def _buchung(admin, konto, betrag, datum, payee="", beschreibung=""):
r = admin.post("/api/transactions", json={
"type": "expense", "date": datum, "account_id": konto["id"],
"amount_cents": betrag, "payee": payee, "description": beschreibung})
assert r.status_code == 201, r.text
return r.json()
def _zuordnen(admin):
r = admin.post("/api/subscriptions/zuordnen")
assert r.status_code == 200, r.text
return r.json()
def _txn(admin, txn_id):
for t in admin.get("/api/transactions").json():
if t["id"] == txn_id:
return t
raise AssertionError("Buchung verschwunden")
def _aufraeumen(admin, abo=None, txns=()):
for t in txns:
admin.delete(f"/api/transactions/{t}")
if abo:
admin.delete(f"/api/subscriptions/{abo['id']}")
# --- Der Normalfall ----------------------------------------------------------
def test_abbuchung_findet_ihren_vertrag(admin):
konto = _konto(admin)
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM 4711")
try:
assert _zuordnen(admin)["zugeordnet"] >= 1
verlauf = admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()
assert verlauf["anzahl"] == 1
assert verlauf["summe_cents"] == 1990
assert verlauf["buchungen"][0]["txn_id"] == b["id"]
finally:
_aufraeumen(admin, abo, [b["id"]])
def test_die_buchung_bleibt_unveraendert(admin):
"""Es ist ein Etikett. Betrag, Konto und Kategorie bleiben, wie sie sind."""
konto = _konto(admin)
abo = _abo(admin, name="Spotify", amount_cents=1299, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, konto, 1299, str(date.today()), payee="SPOTIFY AB")
try:
_zuordnen(admin)
danach = _txn(admin, b["id"])
assert danach["amount_cents"] == 1299
assert danach["account_id"] == konto["id"]
assert danach["date"] == b["date"]
assert danach["type"] == "expense"
finally:
_aufraeumen(admin, abo, [b["id"]])
def test_faelligkeit_rueckt_nach_der_erkannten_abbuchung_weiter(admin):
konto = _konto(admin)
heute = date.today()
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
next_due=str(heute), interval_months=1)
b = _buchung(admin, konto, 1990, str(heute), payee="NETFLIX")
try:
_zuordnen(admin)
danach = admin.get(f"/api/subscriptions/{abo['id']}").json()
assert danach["next_due"] is not None
assert danach["next_due"] > str(heute), "Faelligkeit blieb stehen"
finally:
_aufraeumen(admin, abo, [b["id"]])
def test_jede_abbuchung_wird_einzeln_erkannt(admin):
"""Drei Monate nachgetragen ergeben drei Zeilen im Verlauf, nicht eine."""
konto = _konto(admin)
heute = date.today()
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
next_due=str(heute), interval_months=1)
tage = [heute - timedelta(days=62), heute - timedelta(days=31), heute]
ids = [_buchung(admin, konto, 1990, str(t), payee="NETFLIX")["id"] for t in tage]
try:
_zuordnen(admin)
verlauf = admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()
assert verlauf["anzahl"] == 3, verlauf
assert verlauf["summe_cents"] == 3 * 1990
finally:
_aufraeumen(admin, abo, ids)
# --- Wo das Programm lieber fragt --------------------------------------------
def test_fremder_betrag_wird_nicht_zugeordnet(admin):
"""Lieber nichts sagen als daneben raten."""
konto = _konto(admin)
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, konto, 7350, str(date.today()), payee="BAUMARKT")
try:
_zuordnen(admin)
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 0
finally:
_aufraeumen(admin, abo, [b["id"]])
def test_richtiger_betrag_fremder_text_kommt_als_vorschlag(admin):
"""Auf dem Auszug steht oft der Abwickler, nicht die Marke. Fragen statt
raten – aber die Frage muss gestellt werden."""
konto = _konto(admin)
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, konto, 1990, str(date.today()), payee="STRIPE PAYMENTS")
try:
antwort = _zuordnen(admin)
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0), \
"ungefragt zugeordnet"
passend = [v for v in antwort["vorschlaege"] if v["txn_id"] == b["id"]]
assert passend, antwort["vorschlaege"]
assert passend[0]["subscription_id"] == abo["id"]
finally:
_aufraeumen(admin, abo, [b["id"]])
def test_buchung_auf_fremdem_konto_zaehlt_nicht(admin):
"""Der Vertrag sagt, ueber welches Konto er laeuft – das gilt."""
konto = _konto(admin)
anderes = _konto(admin)
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, anderes, 1990, str(date.today()), payee="NETFLIX")
try:
_zuordnen(admin)
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
finally:
_aufraeumen(admin, abo, [b["id"]])
def test_gekuendigte_vertraege_ziehen_nichts_mehr_an(admin):
konto = _konto(admin)
abo = _abo(admin, name="Altes Abo", amount_cents=1990, account_id=konto["id"],
next_due=str(date.today()), is_active=False)
b = _buchung(admin, konto, 1990, str(date.today()), payee="ALTES ABO")
try:
_zuordnen(admin)
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
finally:
_aufraeumen(admin, abo, [b["id"]])
# --- Die Preiserhoehung ------------------------------------------------------
def test_preiserhoehung_wird_gemeldet_statt_verbucht(admin):
"""Der wertvollste Fall – und einer, den man nie automatisch nachziehen
darf. Eine Preiserhoehung ist eine Nachricht an den Nutzer."""
konto = _konto(admin)
abo = _abo(admin, name="Netflix", amount_cents=1790, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM")
try:
antwort = _zuordnen(admin)
meldungen = [p for p in antwort["preisaenderungen"] if p["txn_id"] == b["id"]]
assert meldungen, antwort
assert meldungen[0]["erwartet_cents"] == 1790
assert meldungen[0]["amount_cents"] == 1990
# Ungefragt geaendert hat sich nichts.
assert admin.get(f"/api/subscriptions/{abo['id']}").json()["amount_cents"] == 1790
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
finally:
_aufraeumen(admin, abo, [b["id"]])
def test_neuen_preis_uebernehmen_und_zuordnen(admin):
"""Der Weg, den der Nutzer danach geht."""
konto = _konto(admin)
abo = _abo(admin, name="Netflix", amount_cents=1790, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM")
try:
r = admin.post(f"/api/subscriptions/{abo['id']}/preis",
params={"amount_cents": 1990})
assert r.status_code == 200, r.text
assert r.json()["amount_cents"] == 1990
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{b['id']}")
assert r.status_code == 200, r.text
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 1
finally:
_aufraeumen(admin, abo, [b["id"]])
def test_ein_vielfaches_gilt_nicht_als_preisaenderung(admin):
"""Ein Abo, das sich verdreifacht, ist kein Abo mehr, sondern etwas
anderes – sonst zieht jeder groessere Einkauf beim selben Haendler an."""
konto = _konto(admin)
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, konto, 19900, str(date.today()), payee="NETFLIX.COM")
try:
antwort = _zuordnen(admin)
assert not [p for p in antwort["preisaenderungen"] if p["txn_id"] == b["id"]]
assert not [v for v in antwort["vorschlaege"] if v["txn_id"] == b["id"]]
finally:
_aufraeumen(admin, abo, [b["id"]])
# --- Das Etikett wieder abziehen ---------------------------------------------
def test_zuordnung_laesst_sich_loesen_und_die_buchung_bleibt(admin):
konto = _konto(admin)
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX")
try:
_zuordnen(admin)
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 1
r = admin.delete(f"/api/subscriptions/buchungen/{b['id']}")
assert r.status_code == 204, r.text
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 0
# Die Buchung selbst steht unveraendert da.
assert _txn(admin, b["id"])["amount_cents"] == 1990
finally:
_aufraeumen(admin, abo, [b["id"]])
def test_geloeschter_vertrag_nimmt_die_buchung_nicht_mit(admin):
"""Wer ein Abo kuendigt und loescht, will nicht seine Zahlungshistorie
mitloeschen."""
konto = _konto(admin)
abo = _abo(admin, name="Kurzlebig", amount_cents=1990, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, konto, 1990, str(date.today()), payee="KURZLEBIG")
try:
_zuordnen(admin)
assert admin.delete(f"/api/subscriptions/{abo['id']}").status_code == 204
danach = _txn(admin, b["id"])
assert danach["amount_cents"] == 1990, "Buchung mitgeloescht"
finally:
admin.delete(f"/api/transactions/{b['id']}")
def test_vorschlaege_veraendern_nichts(admin):
"""Eine Ansicht, die bloss hinsieht, darf nichts zuordnen."""
konto = _konto(admin)
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX")
try:
r = admin.get("/api/subscriptions/vorschlaege")
assert r.status_code == 200, r.text
assert r.json()["zugeordnet"] == 0
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
finally:
_aufraeumen(admin, abo, [b["id"]])
def test_zuordnung_ueberlebt_sichern_und_einspielen(admin):
"""Ohne den Verweis in der Sicherung zeigte das Etikett danach auf einen
FREMDEN Vertrag – die Nummern werden beim Einspielen neu vergeben."""
import json
konto = _konto(admin)
name = "Backup-Abo " + secrets.token_hex(3)
abo = _abo(admin, name=name, amount_cents=1990, account_id=konto["id"],
next_due=str(date.today()))
b = _buchung(admin, konto, 1990, str(date.today()), payee=name)
try:
_zuordnen(admin)
assert _txn(admin, b["id"])["subscription_id"] == abo["id"]
roh = admin.get("/api/export/backup.json").content
haufen = json.loads(roh)
haufen = haufen.get("data", haufen)
# Der Verweis muss ueberhaupt in der Sicherung stehen.
gesichert = [t for t in haufen["transactions"]
if t.get("subscription_id") is not None]
assert gesichert, "kein einziges Etikett gesichert"
r = admin.post("/api/backup/restore",
files={"file": ("backup.json", roh, "application/json")},
data={"mode": "replace"})
assert r.status_code == 200, r.text
# Nach dem Einspielen sind die Nummern andere – die Zuordnung muss
# trotzdem auf DENSELBEN Vertrag zeigen.
abos = admin.get("/api/subscriptions").json()["subscriptions"]
wieder = next(s for s in abos if s["name"] == name)
verlauf = admin.get(f"/api/subscriptions/{wieder['id']}/verlauf").json()
assert verlauf["anzahl"] == 1, "Zuordnung nach dem Einspielen verloren"
assert verlauf["summe_cents"] == 1990
finally:
pass
# --- Zuordnen von Hand lehrt den Vertrag ------------------------------------
def test_haendische_zuordnung_lernt_den_empfaenger(admin):
"""Musste man von Hand ran, hat der Erkennungstext nicht gepasst.
Beim naechsten Mal soll dieselbe Zahlung von selbst fallen – sonst klickt
man jeden Monat dasselbe.
"""
konto = _konto(admin)
abo = _abo(admin, name="Fitness " + secrets.token_hex(3), amount_cents=4990,
account_id=konto["id"])
# Der Auszug nennt den Anbieter ganz anders als der Vertrag heisst.
t1 = _buchung(admin, konto, 4990, "2026-06-05", payee="ACTIV GYM ZUERICH 8004")
t2 = _buchung(admin, konto, 4990, "2026-07-05", payee="ACTIV GYM ZUERICH 8004")
try:
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{t1['id']}")
assert r.status_code == 200, r.text
assert r.json()["payee_pattern"] == "ACTIV GYM ZUERICH 8004"
_zuordnen(admin)
assert _txn(admin, t2["id"])["subscription_id"] == abo["id"]
finally:
_aufraeumen(admin, abo, (t1["id"], t2["id"]))
def test_lernen_verdraengt_den_namen_nicht(admin):
"""Der Name bleibt als zweite Chance – sonst verliert man beim Lernen,
was vorher schon getroffen hat."""
konto = _konto(admin)
abo = _abo(admin, name="Spotify", amount_cents=1295, account_id=konto["id"])
fremd = _buchung(admin, konto, 1295, "2026-06-07", payee="DIGITAL RIVER GMBH")
klassisch = _buchung(admin, konto, 1295, "2026-07-07", payee="SPOTIFY AB")
try:
admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{fremd['id']}")
_zuordnen(admin)
assert _txn(admin, klassisch["id"])["subscription_id"] == abo["id"]
finally:
_aufraeumen(admin, abo, (fremd["id"], klassisch["id"]))
def test_passende_zuordnung_aendert_den_erkennungstext_nicht(admin):
"""Hat der Text schon gepasst, gibt es nichts zu lernen."""
konto = _konto(admin)
abo = _abo(admin, name="Netflix", amount_cents=1799, account_id=konto["id"])
t = _buchung(admin, konto, 1799, "2026-06-09", payee="NETFLIX.COM 4711")
try:
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{t['id']}")
assert r.json()["payee_pattern"] == "Netflix"
finally:
_aufraeumen(admin, abo, (t["id"],))
+300
View File
@@ -0,0 +1,300 @@
"""Abos & Vertraege: verzeichnen und zusammenrechnen.
Der Nutzen dieser Stufe ist eine einzige Zahl – was die Vertraege zusammen im
Monat kosten. Alles andere haengt daran, dass sie stimmt: auch ueber
Waehrungen und ueber verschiedene Intervalle hinweg.
"""
import json
import secrets
from datetime import date, timedelta
from conftest import login_ready, new_user
def _abo(admin, **kw):
body = {"name": "Netflix " + secrets.token_hex(3), "amount_cents": 1790,
"currency": "EUR", "interval_months": 1}
body.update(kw)
r = admin.post("/api/subscriptions", json=body)
assert r.status_code == 201, r.text
return r.json()
def _liste(admin):
r = admin.get("/api/subscriptions")
assert r.status_code == 200, r.text
return r.json()
def _kategorie(admin, name):
for g in admin.get("/api/categories").json():
for c in g["categories"]:
if c["name"] == name:
return c["id"]
raise AssertionError(f"Kategorie '{name}' fehlt")
def test_monatswert_wird_aus_dem_intervall_gerechnet(admin):
"""Nicht gespeichert, sondern gerechnet – sonst veraltet er beim naechsten
Preisaufschlag stillschweigend."""
jahr = _abo(admin, amount_cents=40000, interval_months=12)
assert jahr["monatlich_cents"] == 3333
quartal = _abo(admin, amount_cents=3000, interval_months=3)
assert quartal["monatlich_cents"] == 1000
monat = _abo(admin, amount_cents=1790, interval_months=1)
assert monat["monatlich_cents"] == 1790
def test_preisaenderung_zieht_den_monatswert_mit(admin):
abo = _abo(admin, amount_cents=1790, interval_months=1)
r = admin.patch(f"/api/subscriptions/{abo['id']}", json={"amount_cents": 1990})
assert r.status_code == 200, r.text
assert r.json()["monatlich_cents"] == 1990
def test_summe_zaehlt_gekuendigte_nicht_mit(admin):
vorher = _liste(admin)["summary"]["monatlich_cents"]
abo = _abo(admin, amount_cents=2500, interval_months=1, currency="EUR")
assert _liste(admin)["summary"]["monatlich_cents"] == vorher + 2500
admin.patch(f"/api/subscriptions/{abo['id']}", json={"is_active": False})
assert _liste(admin)["summary"]["monatlich_cents"] == vorher
def test_fremde_waehrung_wird_umgerechnet(admin):
"""Ein deutscher Handyvertrag laeuft in Euro, auch wenn das Konto Franken
fuehrt. Ohne Umrechnung liessen sich die Zeilen nicht addieren."""
from app.database import SessionLocal
from app.models import ExchangeRate
with SessionLocal() as s:
if s.get(ExchangeRate, (date.today(), "CHF")) is None:
s.add(ExchangeRate(date=date.today(), currency="CHF",
rate_per_eur=0.95, source="test"))
s.commit()
abo = _abo(admin, amount_cents=1000, interval_months=1, currency="CHF")
# Hauptwaehrung ist EUR: 10.00 CHF bei 0.95 CHF je EUR sind 10.53 EUR.
assert abo["monatlich_base_cents"] == 1053
assert abo["monatlich_cents"] == 1000, "die eigene Waehrung bleibt unangetastet"
def test_waehrung_ohne_kurs_wird_gemeldet_statt_verschluckt(admin):
"""Lieber sagen, dass eine Zeile fehlt, als die Summe zu klein anzuzeigen."""
abo = _abo(admin, amount_cents=999, interval_months=1, currency="ZAR")
summary = _liste(admin)["summary"]
assert "ZAR" in summary["ohne_kurs"], summary
treffer = next(z for z in _liste(admin)["subscriptions"] if z["id"] == abo["id"])
assert treffer["monatlich_base_cents"] is None
def test_dollar_ist_jetzt_erlaubt(admin):
"""Manche Abos werden in Dollar abgerechnet. Frueher lehnte das Formular
alles ausser EUR und CHF ab."""
abo = _abo(admin, currency="USD", amount_cents=1200)
assert abo["currency"] == "USD"
def test_erfundene_waehrung_wird_abgelehnt(admin):
"""Ohne Kurs liesse sich der Betrag nie umrechnen – er stuende in keiner
Summe. Das faellt spaeter auf und ist dann schwer zu erklaeren."""
r = admin.post("/api/subscriptions", json={
"name": "Fantasie", "amount_cents": 100, "currency": "XYZ",
"interval_months": 1})
assert r.status_code == 422, r.text
def test_krummes_intervall_wird_abgelehnt(admin):
r = admin.post("/api/subscriptions", json={
"name": "Krumm", "amount_cents": 100, "currency": "EUR",
"interval_months": 37})
assert r.status_code == 422, r.text
def test_kategorie_muss_eine_ausgabe_sein(admin):
gehalt = _kategorie(admin, "Gehalt")
r = admin.post("/api/subscriptions", json={
"name": "Falsch", "amount_cents": 100, "currency": "EUR",
"interval_months": 1, "category_id": gehalt})
assert r.status_code == 400, r.text
assert "Ausgabe" in r.json()["detail"]
def test_erkennungstext_wird_aus_dem_namen_vorbelegt(admin):
abo = _abo(admin, name="Spotify Familie")
assert abo["payee_pattern"] == "Spotify Familie"
def test_faelligkeit_zaehlt_die_tage(admin):
abo = _abo(admin, next_due=str(date.today() + timedelta(days=9)))
assert abo["tage_bis_faellig"] == 9
ueberfaellig = _abo(admin, next_due=str(date.today() - timedelta(days=2)))
assert ueberfaellig["tage_bis_faellig"] == -2
def test_sparkonto_laesst_sich_wieder_loesen(admin):
"""Sonst wird man ein einmal gesetztes Pocket nie wieder los."""
konto = admin.post("/api/accounts", json={
"name": "Pocket " + secrets.token_hex(3), "type": "sparkonto",
"currency": "EUR", "opening_balance_cents": 0}).json()
abo = _abo(admin, interval_months=12, amount_cents=40000,
savings_account_id=konto["id"])
assert abo["savings_account_id"] == konto["id"]
r = admin.patch(f"/api/subscriptions/{abo['id']}", json={"savings_account_id": None})
assert r.status_code == 200, r.text
assert r.json()["savings_account_id"] is None
def test_fremdes_konto_wird_nicht_angenommen(admin):
"""Sonst schriebe ein Vertrag seine Zahlungen spaeter in ein Konto, das
einem anderen gehoert."""
fremd = admin.post("/api/accounts", json={
"name": "Fremd " + secrets.token_hex(3), "type": "girokonto",
"currency": "EUR", "opening_balance_cents": 0}).json()
username, password = new_user(admin)
anderer, _ = login_ready(username, password)
r = anderer.post("/api/subscriptions", json={
"name": "Fremdzugriff", "amount_cents": 100, "currency": "EUR",
"interval_months": 1, "account_id": fremd["id"]})
assert r.status_code == 404, r.text
def test_angebotene_waehrungen_sind_eine_einstellung(admin):
"""Die Liste beschraenkt nur die ANZEIGE. Gueltig bleibt alles mit Kurs –
sonst verloere ein Konto seine Waehrung, weil sie gerade nicht angehakt
ist."""
rows = {r["key"]: r["value"] for r in admin.get("/api/settings").json()}
assert rows["active_currencies"] == "EUR,CHF,USD", "Vorgabe passt nicht"
r = admin.put("/api/settings/active_currencies", params={"value": "EUR,CHF"})
assert r.status_code == 200, r.text
try:
# Trotz Abwahl weiter anlegbar: Die Einstellung ist keine Sperre.
abo = _abo(admin, currency="USD", amount_cents=500)
assert abo["currency"] == "USD"
finally:
admin.put("/api/settings/active_currencies", params={"value": "EUR,CHF,USD"})
# --- Markenlogo --------------------------------------------------------------
def _bild(groesse=(600, 400), farbe=(200, 30, 30, 255)) -> bytes:
"""Ein echtes PNG, absichtlich zu gross – so kommt es aus dem Alltag."""
import io as _io
from PIL import Image
puffer = _io.BytesIO()
Image.new("RGBA", groesse, farbe).save(puffer, format="PNG")
return puffer.getvalue()
def _hochladen(admin, sub_id, daten=None, name="logo.png", typ="image/png"):
return admin.put(f"/api/subscriptions/{sub_id}/logo",
files={"file": (name, daten if daten is not None else _bild(), typ)})
def test_logo_wird_klein_gerechnet_und_ausgeliefert(admin):
"""Hochgeladen wird, was das Telefon hergibt – gespeichert ein Bildchen."""
from app.services import logo
abo = _abo(admin)
assert abo["has_logo"] is False
r = _hochladen(admin, abo["id"])
assert r.status_code == 200, r.text
assert r.json()["has_logo"] is True
assert r.json()["logo_version"] is not None
bild = admin.get(f"/api/subscriptions/{abo['id']}/logo")
assert bild.status_code == 200
assert bild.headers["content-type"] == "image/png"
assert len(bild.content) < 20_000, "Logo nicht verkleinert"
from PIL import Image
import io as _io
gespeichert = Image.open(_io.BytesIO(bild.content))
assert max(gespeichert.size) <= logo.MAX_KANTE
# Seitenverhaeltnis bleibt: ein verzerrtes Logo sieht falscher aus als ein
# kleines. 600x400 ist 3:2.
assert gespeichert.size == (128, 85), gespeichert.size
def test_ohne_logo_gibt_es_kein_bild(admin):
abo = _abo(admin)
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
def test_logo_laesst_sich_austauschen_und_entfernen(admin):
abo = _abo(admin)
_hochladen(admin, abo["id"], _bild((300, 300), (10, 200, 10, 255)))
erst = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
_hochladen(admin, abo["id"], _bild((300, 300), (10, 10, 200, 255)))
zweit = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
assert zweit != erst, "das alte Logo blieb stehen"
r = admin.delete(f"/api/subscriptions/{abo['id']}/logo")
assert r.status_code == 200, r.text
assert r.json()["has_logo"] is False
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
def test_was_kein_bild_ist_wird_verstaendlich_abgewiesen(admin):
abo = _abo(admin)
r = _hochladen(admin, abo["id"], b"kein bild, nur text", "notiz.txt", "text/plain")
assert r.status_code == 400, r.text
assert "Bild" in r.json()["detail"]
def test_logo_eines_fremden_vertrags_bleibt_verschlossen(admin):
"""Sonst laege es hinter einer geratenen Nummer offen."""
abo = _abo(admin)
_hochladen(admin, abo["id"])
username, password = new_user(admin)
anderer, _ = login_ready(username, password)
assert anderer.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
def test_geloeschter_vertrag_nimmt_sein_logo_mit(admin):
from app.database import SessionLocal
from app.models import SubscriptionLogo
abo = _abo(admin)
_hochladen(admin, abo["id"])
assert admin.delete(f"/api/subscriptions/{abo['id']}").status_code == 204
with SessionLocal() as s:
assert s.get(SubscriptionLogo, abo["id"]) is None, "Logo blieb verwaist zurueck"
def test_logo_uebersteht_sichern_und_einspielen(admin):
"""Ein Bild ist binaer – ohne Base64 scheitert schon das Schreiben der
Sicherung, und zwar erst, wenn der Nutzer sie zu haben glaubt."""
abo = _abo(admin, name="Logo-Backup " + secrets.token_hex(3))
_hochladen(admin, abo["id"], _bild((200, 200), (7, 77, 177, 255)))
vorher = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
sicherung = admin.get("/api/export/backup.json")
assert sicherung.status_code == 200, sicherung.text
roh = sicherung.content
daten = json.loads(roh)
haufen = daten.get("data", daten)
logos = haufen.get("subscription_logos")
assert logos, f"Logos fehlen in der Sicherung: {sorted(haufen)[:25]}"
admin.delete(f"/api/subscriptions/{abo['id']}/logo")
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
r = admin.post("/api/backup/restore",
files={"file": ("backup.json", roh, "application/json")},
data={"mode": "merge"})
assert r.status_code == 200, r.text
nachher = admin.get(f"/api/subscriptions/{abo['id']}/logo")
assert nachher.status_code == 200, "Logo kam nicht zurueck"
assert nachher.content == vorher, "Logo kam veraendert zurueck"
+229 -2
View File
@@ -34,8 +34,17 @@ def test_starter_categories_present(admin):
def test_currency_validation(admin): def test_currency_validation(admin):
r = admin.post("/api/accounts", json={"name": "X", "currency": "USD"}) """Waehrungen mit EZB-Referenzkurs sind erlaubt, erfundene nicht.
assert r.status_code == 422
Frueher standen hier nur EUR und CHF. Ohne Kurs laesst sich ein Betrag
aber nie umrechnen – er stuende in keiner Summe und in keinem Budget, und
genau daran haengt die Grenze, nicht an einer Vorliebe.
"""
r = admin.post("/api/accounts", json={"name": "Dollar", "currency": "USD"})
assert r.status_code == 201, r.text
r = admin.post("/api/accounts", json={"name": "Fantasie", "currency": "XYZ"})
assert r.status_code == 422, r.text
def test_identifiers_stored_and_deduped(admin): def test_identifiers_stored_and_deduped(admin):
@@ -53,6 +62,26 @@ def test_identifiers_stored_and_deduped(admin):
assert ("card_no", "1234") in kinds assert ("card_no", "1234") in kinds
def test_update_account_keeps_identifier(admin):
"""Umbenennen mit unverändertem Identifikator darf nicht an der UNIQUE-
Bedingung scheitern (Regression: früheres clear()+neu-Anlegen)."""
acc = _mk_account(admin, name="Revolut CHF",
identifiers=[{"kind": "text", "value": "Revolut**5064*"}])
r = admin.patch(f"/api/accounts/{acc['id']}", json={
"name": "Revolut CHF neu",
"identifiers": [{"kind": "text", "value": "Revolut**5064*"}],
})
assert r.status_code == 200, r.text
assert r.json()["name"] == "Revolut CHF neu"
idents = r.json()["identifiers"]
assert len(idents) == 1 and idents[0]["value"] == "Revolut**5064*"
# Identifikator entfernen klappt ebenfalls.
r2 = admin.patch(f"/api/accounts/{acc['id']}", json={"name": "Ohne", "identifiers": []})
assert r2.status_code == 200, r2.text
assert r2.json()["identifiers"] == []
def test_balance_and_cross_currency_transfer(admin): def test_balance_and_cross_currency_transfer(admin):
_seed_recent_rates("0.95") _seed_recent_rates("0.95")
d = date.today() d = date.today()
@@ -82,3 +111,201 @@ def test_transfer_requires_other_account(admin):
"to_account_id": eur["id"], "amount_cents": 100, "to_account_id": eur["id"], "amount_cents": 100,
}) })
assert r.status_code == 400 assert r.status_code == 400
def test_stichtag_excludes_earlier_bookings(admin):
"""Der Anfangssaldo gilt am Ende des Stichtags.
Buchungen bis einschliesslich dieses Tages stecken schon darin und dürfen den
Kontostand nicht ein zweites Mal bewegen; erst spätere zählen. Ohne die
Datumsprüfung addierte die Berechnung jede Buchung des Kontos – der Stichtag
war reine Dekoration und Salden liefen um die Vorgeschichte daneben.
"""
stichtag = date(2026, 3, 31)
acc = _mk_account(admin, name="Lohn", currency="EUR",
opening_balance_cents=20000, opening_date=str(stichtag))
def saldo() -> int:
return admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"]
# Vor dem Stichtag: bleibt buchbar, ändert den Saldo aber nicht.
admin.post("/api/transactions", json={"type": "expense", "date": "2026-02-10",
"account_id": acc["id"], "amount_cents": 7000})
assert saldo() == 20000
# Am Stichtag selbst: ebenfalls schon im Anfangssaldo enthalten.
admin.post("/api/transactions", json={"type": "income", "date": str(stichtag),
"account_id": acc["id"], "amount_cents": 5000})
assert saldo() == 20000
# Erst der Tag danach bewegt den Saldo.
admin.post("/api/transactions", json={"type": "income", "date": "2026-04-01",
"account_id": acc["id"], "amount_cents": 1500})
assert saldo() == 21500
# Die alten Buchungen sind weiterhin da – nur der Saldo lässt sie aus.
liste = admin.get("/api/transactions", params={"account_id": acc["id"]}).json()
daten = {t["date"] for t in (liste["items"] if isinstance(liste, dict) else liste)}
assert {"2026-02-10", "2026-03-31", "2026-04-01"} <= daten
def test_stichtag_gilt_je_konto_beim_transfer(admin):
"""Beim Übertrag wird jede Seite an ihrem eigenen Stichtag gemessen."""
von = _mk_account(admin, name="Von", currency="EUR",
opening_balance_cents=50000, opening_date="2026-03-31")
nach = _mk_account(admin, name="Nach", currency="EUR",
opening_balance_cents=1000, opening_date="2026-01-31")
# Liegt vor dem Stichtag von "Von", aber nach dem von "Nach".
r = admin.post("/api/transactions", json={
"type": "transfer", "date": "2026-02-15", "account_id": von["id"],
"to_account_id": nach["id"], "amount_cents": 30000,
})
assert r.status_code == 201, r.text
assert admin.get(f"/api/accounts/{von['id']}").json()["balance_cents"] == 50000
assert admin.get(f"/api/accounts/{nach['id']}").json()["balance_cents"] == 31000
def test_ohne_stichtag_zaehlt_alles(admin):
acc = _mk_account(admin, name="Ohne", currency="EUR", opening_balance_cents=10000)
admin.post("/api/transactions", json={"type": "expense", "date": "2020-01-01",
"account_id": acc["id"], "amount_cents": 2500})
assert admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"] == 7500
def test_konto_loeschen_laesst_fremden_saldo_unberuehrt(admin):
"""Ein gelöschtes Konto darf den Saldo eines anderen nicht verändern.
``to_account_id`` hängt an ON DELETE CASCADE. Wurde das Zielkonto gelöscht,
verschwand die ganze Buchung – auch die Abgangsseite, die dem Quellkonto
gehört. Dessen Saldo stieg dadurch stillschweigend um den Übertrag.
"""
quelle = _mk_account(admin, name="Quelle", currency="CHF", opening_balance_cents=100000)
ziel = _mk_account(admin, name="Depot", currency="CHF")
r = admin.post("/api/transactions", json={
"type": "transfer", "date": "2026-03-01", "account_id": quelle["id"],
"to_account_id": ziel["id"], "amount_cents": 30000,
})
assert r.status_code == 201, r.text
assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == 70000
assert admin.delete(f"/api/accounts/{ziel['id']}").status_code == 204
# Das Geld ist abgeflossen und bleibt abgeflossen.
assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == 70000
verbleibend = [t for t in admin.get("/api/transactions").json()
if t["account_id"] == quelle["id"] and t["date"] == "2026-03-01"]
assert len(verbleibend) == 1
assert verbleibend[0]["type"] == "expense"
def test_konto_loeschen_erhaelt_zugang_beim_zielkonto(admin):
"""Umgekehrt: War das gelöschte Konto die Quelle, bleibt der Zugang beim Ziel."""
quelle = _mk_account(admin, name="Weg", currency="CHF", opening_balance_cents=50000)
ziel = _mk_account(admin, name="Bleibt", currency="CHF")
admin.post("/api/transactions", json={
"type": "transfer", "date": "2026-03-02", "account_id": quelle["id"],
"to_account_id": ziel["id"], "amount_cents": 20000,
})
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000
assert admin.delete(f"/api/accounts/{quelle['id']}").status_code == 204
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000
def test_kontenreihenfolge_laesst_sich_setzen(admin):
"""Die Liste stand in der Anlegereihenfolge; die will man selbst bestimmen."""
a = _mk_account(admin, name="Zuerst angelegt")
b = _mk_account(admin, name="Danach angelegt")
c = _mk_account(admin, name="Zuletzt angelegt")
namen = lambda: [x["name"] for x in admin.get("/api/accounts").json()
if x["name"].endswith("angelegt")]
assert namen() == ["Zuerst angelegt", "Danach angelegt", "Zuletzt angelegt"]
r = admin.put("/api/accounts/order", json={"ids": [c["id"], a["id"], b["id"]]})
assert r.status_code == 204, r.text
assert namen() == ["Zuletzt angelegt", "Zuerst angelegt", "Danach angelegt"]
def test_kontenreihenfolge_nimmt_keine_fremden_konten(admin, client):
"""Fremde Konto-IDs in der Liste dürfen nichts verschieben."""
eigen = _mk_account(admin, name="Eigenes Konto")
r = admin.put("/api/accounts/order", json={"ids": [eigen["id"], 999999]})
assert r.status_code == 404
def test_saldo_laesst_sich_aufschluesseln(admin):
"""Stimmt ein Kontostand nicht, liegt es fast immer an einer dieser Zahlen
oder an einem Uebertrag. Ohne diese Ansicht sucht man in einer langen
Buchungsliste."""
import secrets
a = admin.post("/api/accounts", json={
"name": f"Aufschluss-A {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 100000}).json()
b = admin.post("/api/accounts", json={
"name": f"Aufschluss-B {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
admin.post("/api/transactions", json={
"type": "income", "date": "2026-08-01", "account_id": a["id"],
"amount_cents": 50000, "payee": "Lohn"})
admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-02", "account_id": a["id"],
"amount_cents": 20000, "payee": "Miete"})
admin.post("/api/transactions", json={
"type": "transfer", "date": "2026-08-03", "account_id": a["id"],
"to_account_id": b["id"], "amount_cents": 30000})
r = admin.get(f"/api/accounts/{a['id']}/aufschluesselung")
assert r.status_code == 200, r.text
teile = r.json()["teile"]
assert teile["anfangsbestand"]["cents"] == 100000
assert teile["einnahmen"] == {"cents": 50000, "anzahl": 1}
assert teile["ausgaben"] == {"cents": 20000, "anzahl": 1}
assert teile["uebertraege_raus"] == {"cents": 30000, "anzahl": 1}
assert teile["uebertraege_rein"] == {"cents": 0, "anzahl": 0}
assert teile["saldo"]["cents"] == 100000 + 50000 - 20000 - 30000
# Und die Rechnung muss dasselbe sagen wie die Kontenliste.
assert admin.get(f"/api/accounts/{a['id']}").json()["balance_cents"] == \
teile["saldo"]["cents"]
# Das Uebertrags-Bein steht einzeln da – dort sucht man zuerst.
beine = r.json()["uebertraege"]
assert len(beine) == 1 and beine[0]["raus"] is True
assert beine[0]["cents"] == 30000 and beine[0]["nach"] == b["name"]
# Auf der Gegenseite dasselbe Bein, nur andersherum.
gegen = admin.get(f"/api/accounts/{b['id']}/aufschluesselung").json()
assert gegen["teile"]["uebertraege_rein"] == {"cents": 30000, "anzahl": 1}
assert gegen["uebertraege"][0]["raus"] is False
def test_aufschluesselung_zeigt_was_der_stichtag_ausschliesst(admin):
"""Buchungen bis zum Stichtag stehen in der Liste, zaehlen aber nicht in
den Kontostand – sie stecken schon im Anfangsbestand. In der Oberflaeche
war das unsichtbar, und der Unterschied zwischen Gesehenem und Gerechnetem
nicht zu erklaeren."""
import secrets
konto = admin.post("/api/accounts", json={
"name": f"Stichtag {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 120000,
"opening_date": "2026-08-15"}).json()
# Vor dem Stichtag – steckt schon im Anfangsbestand.
admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-10", "account_id": konto["id"],
"amount_cents": 150000, "payee": "Alt"})
# Danach – zaehlt.
admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-20", "account_id": konto["id"],
"amount_cents": 30000, "payee": "Neu"})
teile = admin.get(f"/api/accounts/{konto['id']}/aufschluesselung").json()["teile"]
assert teile["stichtag"] == "2026-08-15"
assert teile["vor_stichtag"] == {"cents": -150000, "anzahl": 1}
assert teile["ausgaben"] == {"cents": 30000, "anzahl": 1}, "nur die nach dem Stichtag"
assert teile["saldo"]["cents"] == 120000 - 30000
# Und die Kontenliste sagt dasselbe.
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 90000
+153
View File
@@ -0,0 +1,153 @@
"""Die Geraetemarke der nativen App.
Die API kannte bisher nur zwei Zustaende: angemeldet per Sitzungs-Cookie, oder
eine enge Kurzbefehl-Marke, die ausschliesslich erfassen darf. Eine native App
passt in keinen davon - sie kann kein Cookie-Formular ausfuellen und soll das
Passwort nicht auf dem Geraet halten.
Deshalb die Reichweite ``app``: gilt wie eine Anmeldung, liegt im
Schluesselbund, ist einzeln widerrufbar. Die enge Marke bleibt eng - das ist
der Punkt, an dem diese Datei am genauesten hinsieht.
"""
import secrets
def _marke(client, name=None, scope=None):
koerper = {"name": name or f"Geraet {secrets.token_hex(3)}"}
if scope is not None:
koerper["scope"] = scope
r = client.post("/api/inbox/tokens", json=koerper)
assert r.status_code == 201, r.text
return r.json()
def _mit(token):
return {"X-Certain-Token": token}
def test_app_marke_darf_lesen_und_schreiben(admin):
"""Der ganze Zweck: Mit der Marke allein, ohne Cookie, muss die App
arbeiten koennen."""
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
marke = _marke(admin, scope="app")
assert marke["scope"] == "app"
konto = admin.post("/api/accounts", json={
"name": f"Marke {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
# Ein frischer Client OHNE Cookie - nur der Header.
roh = TestClient(fastapi_app)
r = roh.get("/api/accounts", headers=_mit(marke["token"]))
assert r.status_code == 200, r.text
assert konto["id"] in {a["id"] for a in r.json()}
# Lesen reicht nicht, schreiben muss auch gehen.
r = roh.post("/api/transactions", headers=_mit(marke["token"]), json={
"type": "expense", "date": "2026-07-14", "account_id": konto["id"],
"amount_cents": 1234, "payee": "Mit Marke gebucht"})
assert r.status_code == 201, r.text
# Und die Budgetansicht, der eigentliche Grund fuer die App.
assert roh.get("/api/budget/current", headers=_mit(marke["token"])).status_code == 200
def test_kurzbefehl_marke_bleibt_eng(admin):
"""Der wichtigste Test hier. Die alte Marke darf durch die neue Pruefung
NICHT still zu einem Vollzugang geworden sein."""
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
marke = _marke(admin) # ohne scope -> quick_add
assert marke["scope"] == "quick_add"
roh = TestClient(fastapi_app)
# Ihr eigener Endpunkt geht weiterhin.
assert roh.get("/api/quick/accounts", headers=_mit(marke["token"])).status_code == 200
# Alles andere nicht.
for pfad in ("/api/accounts", "/api/transactions", "/api/budget/current",
"/api/subscriptions", "/api/categories"):
r = roh.get(pfad, headers=_mit(marke["token"]))
assert r.status_code == 401, f"{pfad} war mit einer quick_add-Marke erreichbar"
def test_ohne_marke_und_mit_falscher_marke_kommt_niemand_herein(admin):
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
roh = TestClient(fastapi_app)
assert roh.get("/api/accounts").status_code == 401
assert roh.get("/api/accounts", headers=_mit("gibtesnicht")).status_code == 401
assert roh.get("/api/accounts", headers=_mit("")).status_code == 401
def test_fremde_marke_sieht_nur_ihre_eigenen_daten(admin):
"""Mandanten-Trennung gilt auch auf dem Marken-Weg."""
from conftest import login_ready, new_user
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
meins = admin.post("/api/accounts", json={
"name": f"Admin-Konto {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
anderer, _ = login_ready(*new_user(admin))
fremde_marke = _marke(anderer, scope="app")
roh = TestClient(fastapi_app)
r = roh.get("/api/accounts", headers=_mit(fremde_marke["token"]))
assert r.status_code == 200
assert meins["id"] not in {a["id"] for a in r.json()}
assert roh.get(f"/api/accounts/{meins['id']}",
headers=_mit(fremde_marke["token"])).status_code == 404
def test_eine_app_marke_stellt_keine_weitere_aus(admin):
"""Sonst stellte sich ein Dieb Ersatzschluessel aus und das Widerrufen der
gestohlenen Marke liefe ins Leere."""
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
marke = _marke(admin, scope="app")
roh = TestClient(fastapi_app)
r = roh.post("/api/inbox/tokens", headers=_mit(marke["token"]),
json={"name": "Ersatzschluessel", "scope": "app"})
assert r.status_code == 403, r.text
# Auch keine enge Marke.
assert roh.post("/api/inbox/tokens", headers=_mit(marke["token"]),
json={"name": "Auch nicht"}).status_code == 403
# Lesen der eigenen Markenliste bleibt erlaubt.
assert roh.get("/api/inbox/tokens", headers=_mit(marke["token"])).status_code == 200
def test_widerrufen_sperrt_sofort(admin):
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
marke = _marke(admin, scope="app")
roh = TestClient(fastapi_app)
assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 200
assert admin.delete(f"/api/inbox/tokens/{marke['id']}").status_code == 204
assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 401
def test_marke_merkt_sich_wann_sie_zuletzt_benutzt_wurde(admin):
"""Damit man im Nachhinein sieht, welches Geraet noch laeuft - und welches
man bedenkenlos widerrufen kann."""
from app.main import app as fastapi_app
from fastapi.testclient import TestClient
marke = _marke(admin, scope="app")
assert marke["last_used_at"] is None
TestClient(fastapi_app).get("/api/accounts", headers=_mit(marke["token"]))
danach = next(t for t in admin.get("/api/inbox/tokens").json() if t["id"] == marke["id"])
assert danach["last_used_at"] is not None
+76
View File
@@ -52,3 +52,79 @@ def test_db_backup_admin_only(admin):
assert other.get("/api/backup/db").status_code == 403 assert other.get("/api/backup/db").status_code == 403
# ...aber seinen eigenen Export schon (leer). # ...aber seinen eigenen Export schon (leer).
assert other.get("/api/export/all.json").status_code == 200 assert other.get("/api/export/all.json").status_code == 200
def test_jede_nutzertabelle_ist_im_backup_beruecksichtigt():
"""Neue Tabellen dürfen nicht stillschweigend aus dem Backup fallen.
Sieben Tabellen sind genau so entstanden: angelegt, migriert, benutzt – und
in ``backup_data`` nie eingetragen. Aufgefallen wäre das erst beim
Wiedereinspielen, also im schlechtesten Moment. Dieser Test zwingt jede
künftige Tabelle mit ``owner_id`` zu einer bewussten Entscheidung: entweder
sichern oder hier begründet ausnehmen.
"""
from app.models import Base
from app.services import backup_data
gesichert = {
spec.model.__tablename__
for liste in (backup_data.USER_TABLES, backup_data.SYSTEM_USER_TABLES,
backup_data.GLOBAL_TABLES)
for spec in liste
}
# Bewusst ausgenommen – die Begründung steht in backup_data.py.
ausgenommen = {"mail_sources", "mail_senders", "pending_uploads",
"bank_apps", "bank_connections", "bank_account_links"}
fehlend = sorted(
m.class_.__tablename__
for m in Base.registry.mappers
if "owner_id" in m.class_.__table__.columns
and m.class_.__tablename__ not in gesichert
and m.class_.__tablename__ not in ausgenommen
)
assert not fehlend, (
f"Diese Tabellen gehören dem Nutzer, stehen aber in keiner Backup-Liste: "
f"{fehlend}. Entweder in USER_TABLES aufnehmen oder oben begründet ausnehmen."
)
def test_transfer_schlagwort_behaelt_sein_zielkonto(admin):
"""Ein Transfer-Schlagwort mit festem Zielkonto trug beim Wiedereinspielen
die ALTE Konto-Nummer in die neue Datenbank. Beim Einspielen brach dann die
ganze Sicherung mit einem Fremdschluessel-Fehler ab - nicht nur diese eine
Zeile. Wer je ein Zielkonto gewaehlt hatte, kam an seine Sicherung also gar
nicht mehr heran."""
import secrets
def _konto(name):
return admin.post("/api/accounts", json={
"name": name, "type": "sparkonto", "currency": "CHF",
"opening_balance_cents": 0}).json()
# Erst Luecken in die Konto-Nummern reissen, dann das Zielkonto anlegen.
# Ohne Luecken vergibt SQLite beim Einspielen zufaellig dieselben Nummern
# und der Fehler bliebe unsichtbar - genau deshalb ist er so lange
# unbemerkt geblieben.
weg = [_konto(f"Weg {secrets.token_hex(3)}") for _ in range(5)]
ziel = _konto(f"Sparen {secrets.token_hex(3)}")
for k in weg:
assert admin.delete(f"/api/accounts/{k['id']}").status_code in (200, 204)
wort = f"um chf sparen {secrets.token_hex(5)}"
r = admin.post("/api/categories/transfer-keywords",
json={"word": wort, "target_account_id": ziel["id"]})
assert r.status_code == 201, r.text
daten = json.loads(admin.get("/api/export/backup.json").content)
r = admin.post("/api/backup/restore",
files={"file": ("backup.json", json.dumps(daten).encode("utf-8"),
"application/json")},
data={"mode": "replace"})
assert r.status_code == 200, r.text
konten = {a["name"]: a["id"] for a in admin.get("/api/accounts").json()}
regel = next(w for w in admin.get("/api/categories/transfer-keywords").json()
if w["word"] == wort)
assert regel["target_account_id"] == konten[ziel["name"]], \
"das Zielkonto muss auf die NEUE Nummer zeigen"

Some files were not shown because too many files have changed in this diff Show More