Files
certain/ios/UEBERGABE.md
T
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

16 KiB

Übergabe: iOS-App für Certain

Für den nächsten Agenten. Diese Datei ersetzt die Unterhaltung, in der die App geplant wurde. Sie enthält alles, was du brauchst, ohne sie gelesen zu haben: was entschieden ist, was fertig ist, was als Nächstes dran ist, und was ich über das Backend herausgefunden habe. Lies sie ganz, bevor du anfängst — die Abschnitte „Was das Backend vorgibt" und „Fallen" ersparen dir Tage.

Stand: 20.10.2026, Commit 6b86a6b. Alles committet und gepusht.


1. Worum es geht

Certain ist ein Haushaltsbuch: FastAPI-Backend (backend/) plus React-Weboberfläche (web/), die das Backend mit ausliefert. Dazu kommt eine native iOS-App in ios/, die sich über eine eingegebene Server-Adresse (wie budget.scarriffle.com) an dasselbe Backend hängt.

Ziel: alles, was die Weboberfläche kann. Budget, Buchungen, Konten, Abos, Dashboard, Arbeitsliste, Kategorien, Tags, Einstellungen, Bankabruf und der volle CSV-Import.

Die wichtigste Rahmenbedingung

Auf der Maschine, auf der der Code entsteht, gibt es kein Xcode. Der Swift- Code wird blind geschrieben und nie übersetzt. Der Nutzer legt das Projekt an, übersetzt und meldet die Fehler zurück.

Daraus folgt die Arbeitsweise: kleine Stufen, jede für sich übersetzbar. Nicht zehntausend Zeilen schreiben und dann einen Berg Fehlermeldungen hinterlassen. Nach jeder Stufe übersetzt der Nutzer.


2. Entscheidungen des Nutzers

Alle schon gefallen. Nicht erneut fragen.

Frage Festgelegt
Backend erweitern Ja, kleine Ergänzungen — jede einzeln vorlegen, bevor du sie schreibst
CSV-Import Voll, wie im Web (trotz Abraten; steht deshalb als letzte Stufe)
Zusätzlicher Umfang Arbeitsliste/Eingang, Kategorien und Tags, Einstellungen, Bankabruf anstossen
Dashboard Feste iOS-Ansicht, kein Raster
iOS-Mindestversion iOS 26
Ablieferung Flache Ordner unter ios/, der Nutzer legt das Xcode-Projekt an
Anmeldung App-Token (im Backend ergänzt, siehe unten)
Erweiterungen Teilen-Menü für Dateien, Kurzbefehle/Siri
Sprachen Deutsch und Englisch
iPad Richtig mitgedacht, zweispaltig
Offline Zuletzt Geladenes mit „Stand von …"

Der Nutzer will jede Entscheidung selbst treffen. Wenn eine Frage auftaucht, die den Zuschnitt ändert: fragen, nicht annehmen.


3. Was das Backend vorgibt

Die Befunde aus einer gründlichen Durchsicht. Sie bestimmen den ganzen Entwurf.

Anmeldung

Die Web-API kennt ausschliesslich ein Sitzungs-Cookie (get_current_user in backend/app/deps.py). Kein Bearer, kein Token im Body. Die Sitzung liegt serverseitig in der Tabelle sessions (kein JWT), hält 30 Tage und verlängert sich nicht.

Anmeldung ist ein Formular, kein JSON: POST /api/auth/login mit application/x-www-form-urlencoded, Feldern username und password (OAuth2PasswordRequestForm). JSON gibt 422.

Zwei-Faktor: Login antwortet {"status": "2fa_required"} und setzt ein Pending-Cookie mit 10 Minuten Laufzeit. Dann POST /api/auth/2fa/verify mit {"code": "...", "recovery": false}. Nach fünf Fehlversuchen ist die Zwischensitzung verbraucht.

Offene Pflichten sperren jeden Endpunkt mit 403 und einem maschinenlesbaren detail: password_change_required oder mfa_setup_required. Erreichbar bleiben nur /health, /version, /auth/me, /auth/logout, /auth/login, /auth/change-password, /auth/2fa.

Alles hängt unter /api

GET /api/version → {"name": "Certain Finance", ...}. Das ist die Serverprüfung. Wichtig: Jeder unbekannte Pfad ausserhalb /api liefert mit Status 200 die Weboberfläche aus — nur /api/version kann einen fremden Server sicher ausschliessen.

Fehlerformat

{"detail": "..."} — aber bei 422 ist detail eine Liste von Pydantic-Meldungen statt einer Zeichenkette. Netzwerk/APIFehler.swift fängt beides ab; wer das übersieht, verliert bei jedem Eingabefehler die Meldung.

Statuscodes: 401 nicht angemeldet, 403 verboten oder Pflicht offen, 404 auch für fremde IDs (bewusst, Mandanten-Isolation), 409 heisst „nachfragen" und lässt sich oft mit trotzdem=true wiederholen, 400/422 ungültig.

Beträge

Alles sind Cent-Ganzzahlen. Nirgends Fliesskomma (einzige Ausnahme: QuickBookingIn.amount). Drei Felder an einer Buchung:

  • amount_cents — in der Kontowährung
  • amount_eur_cents — beim Speichern eingefrorener EUR-Wert
  • amount_base_cents — bei jedem Lesen in die Hauptwährung umgerechnet

Der Client rechnet nichts davon selbst. Kontosalden, Budget-Soll/Ist, Vertrags-Monatswerte, Dashboard-Zahlen kommen alle fertig. Der Client formatiert nur — und rechnet Prozente und „Rest", die es in der API nicht gibt.

Achtung: *_base_cents kann still auf den nativen Wert zurückfallen, wenn kein Wechselkurs vorliegt. Die ohne_kurs-Listen in den Antworten sagen, wann das passiert. Wer sie nicht anzeigt, zeigt stillschweigend falsche Summen.

Zeitstempel — die Falle, die mich Zeit gekostet hat

created_at und Konsorten kommen als 2026-10-20T14:18:15.780705 — ohne Zeitzone, mit sechs Stellen Mikrosekunden. Beide Standard-ISO8601-Leser von Foundation scheitern daran, weil sie eine Zone verlangen. Modelle/Zeitangaben.swift hat deshalb einen dritten Leser. Tagesdaten (date, opening_date) sind dagegen reine "2026-07-14". Zwei getrennte Typen statt einer Decoder-Strategie — das ist Absicht, siehe Kommentar dort.

Zwei Dinge, die fachlich im Frontend sitzen

Die App muss sie nachbauen, das Backend hilft nicht:

  1. Was ein Übertrag ist. Entweder type == "transfer" oder die Buchung steht in GET /api/transactions/links. Es gibt zwei Mechanismen nebeneinander: ein Übertrag am Stück (eine Buchung mit to_account_id) und die Verknüpfungs-Schicht TransferLink (zwei getrennte Buchungen, wie die Bank sie liefert; keine davon wird verändert). Das Zusammenfassen beider Beine zu einer Anzeigezeile macht heute nur der Browser (web/src/pages/Transactions.jsx, Suche nach paarZu).
  2. Welche Verträge in aus_vertraegen_cents stecken. Das Backend liefert nur die Summe; welche Abos dahinterstehen, ermittelt das Frontend selbst aus /subscriptions nach category_id.

Buchungsliste ohne Suche

GET /api/transactions kennt nur account_id, category_id, uncategorized, type, date_from, date_to, limit (Standard 5000), offset. Keine Volltextsuche, kein Tag-Filter, keine wählbare Sortierung. Die Weboberfläche lädt alles und filtert im Browser. Auf dem Telefon ist das spürbar — daher Backend-Ergänzung B unten.

Was nicht in die App kommt

  • Bankkonten verbinden. Die Bank leitet fest auf {base_url}/bank/rueckkehr zurück, registriert bei Enable Banking. Abrufen, Salden holen und Konten zuordnen läuft nativ — nur das Herstellen nicht.
  • Postfach einrichten (IMAP-Zugangsdaten) und Sicherung/Wiederherstellung (ersetzt die ganze Datenbank). Gehört an den Rechner.
  • Benutzerverwaltung (/users, /policy) — Administration, weggelassen, solange der Nutzer nicht widerspricht.
  • Belege als Anhang gibt es nicht. Im Datenmodell existiert nichts dergleichen. POST /api/documents/preview ist eine Einbahnstrasse: Bild rein, erkannter Text und Buchungsvorschläge raus, die Datei wird nicht gespeichert.

4. Backend-Ergänzungen

Vier geplant. A ist fertig, B bis D stehen aus. Jede einzeln dem Nutzer vorlegen, bevor du sie schreibst.

A — App-Token ✅ (Commit 8a7545b)

TokenScope hat neben quick_add jetzt app. get_current_user akzeptiert den Header X-Certain-Token, wenn die Marke diese Reichweite hat. Kein Migrationsschritt nötig — _enum() legt Enums ohne DB-Bedingung ab.

Die Prüfung sitzt in _session_user (backend/app/deps.py), nicht in get_current_user — damit auch enforce_account_state die Marke sieht und eine App nicht an der Passwortwechsel-Pflicht vorbeikommt.

Zwei Dinge, die die Tests festhalten (backend/tests/test_app_marke.py, 7 Stück):

  • Eine quick_add-Marke ist nicht still zum Vollzugang geworden. Sie gilt weiter nur an den drei /quick-Endpunkten.
  • Eine App-Marke darf keine weitere ausstellen (require_cookie_session an POST /api/inbox/tokens). Sonst stellte ein Dieb sich Ersatzschlüssel aus und das Widerrufen liefe ins Leere.

B — Suche und Tag-Filter auf /transactions (offen)

Drei Query-Parameter: q (Volltext über payee, description, notes), tag_id, subscription_id. Muster für den Tag-Filter steht schon da: der uncategorized-Zweig in backend/app/routers/transactions.py arbeitet mit genau so einer Unterabfrage.

Nötig für Stufe 2 — ohne das lädt das Telefon für jede Ansicht bis zu 5000 Zeilen.

C — GET /budget/template liefert modus und zuschlag_cents (offen)

Beide fehlen heute in der Antwort (backend/app/routers/budgets.py), obwohl das Modell sie führt und PUT sie zurückgibt. Ohne sie kann die App die Vorlage weder richtig anzeigen noch bearbeiten. Nötig für Stufe 3.

D — Zwei Kopier-Lücken im Budget (offen)

copy_template_to_month und template_from_month in backend/app/services/budget.py übernehmen nur planned_cents. Ein „berechneter" Eintrag fällt beim Kopieren still auf „fest" zurück. Das ist ein bestehender Fehler, kein App-Thema — aber die App macht ihn sichtbar, weil sie beide Richtungen anbietet. Nötig für Stufe 3.


5. Was fertig ist — Stufe 1 ✅ (Commit 6b86a6b)

21 Swift-Dateien, rund 2600 Zeilen, in ios/:

Ordner Inhalt
App/ CertainApp (Einstieg), AppZustand (zentraler Zustand), RootAnsicht (Navigation)
Netzwerk/ CertainClient (Actor um URLSession), Endpunkte, APIFehler, Ablage (Voreinstellungen + Schlüsselbund), Zwischenspeicher (offline), Lader
Modelle/ Grundmodelle (Benutzer, Marke, Einstellungen, Serverkennung), Konto, Zeitangaben
Bausteine/ Geldbetrag, Darstellung (Farben, Symbolzuordnung, Plakette), Zustandsansichten
Anmeldung/ Server-Adresse, Anmeldung, zweiter Faktor, Passwortwechsel
Konten/ KontenListe — die erste Fachansicht
Einstellungen/ Platzhalter: zeigt den Zugang und lässt abmelden
Texte/ de.lproj und en.lproj, zeilengleich
Tests/ GrundlagenTests — reine Logik, Swift Testing

Wie die Anmeldung abläuft

  1. Nutzer gibt die Adresse ein → GET /api/version prüft, ob dort Certain läuft.
  2. Nutzer meldet sich an → normales Cookie, inklusive 2FA und Pflicht-Passwortwechsel.
  3. Noch in derselben Sitzung legt die App über POST /api/inbox/tokens mit scope: "app" eine Marke an und legt den Klartext in den Schlüsselbund.
  4. Danach trägt nur noch der Header. Das Cookie wird weggeworfen (vergissCookies()).

Kein Passwort auf dem Gerät, kein Ablauf nach 30 Tagen, einzeln widerrufbar.

Was geprüft ist

Jedes Modellfeld wurde gegen echte Antworten eines laufenden Backends verglichen (/api/version, /api/auth/me, /api/accounts, /api/settings, /api/inbox/tokens). Alle Namen stimmen. Mach das bei jeder weiteren Stufe genauso — das ist die einzige Prüfung, die beim blinden Schreiben wirklich etwas fängt. Wie: Ein kleines Python-Skript mit TestClient gegen eine Wegwerf-Datenbank, Felder flach ausgeben, mit den CodingKeys vergleichen. (Das Passwort der Dev-Datenbank unter backend/data/app.db ist unbekannt — nicht raten, eine frische Testdatenbank nehmen.)

Was nicht geprüft ist

Ob es übersetzt. Der Nutzer hatte Stufe 1 beim Schreiben dieser Datei noch nicht gebaut. Rechne damit, dass seine erste Rückmeldung Fehlermeldungen sind — das ist eingeplant und kein Rückschlag.


6. Was als Nächstes dran ist

Stufe 2 — Lesen. Dashboard (feste Ansicht: Kontostände, Budget-Fortschritt, letzte Buchungen, Einnahmen/Ausgaben), Budget-Monatsansicht, Buchungsliste, Abo-Liste. Dazu Backend-Ergänzungen B und C.

Stufe 3 — Schreiben. Buchungen anlegen und ändern, Budget bearbeiten (Monat und Vorlage, beide Betriebsarten fest/berechnet), Abos verwalten samt Logo, Konten bearbeiten. Dazu Backend-Ergänzung D.

Stufe 4 — Arbeitsliste. Eingang mit Buchen, Verwerfen, Kategorie zuordnen, Übertragsvorschlägen und Dubletten. Fachlich der Teil, der am besten aufs Telefon gehört: eine Zeile, eine Entscheidung.

Stufe 5 — Nebenräume. Kategorien, Tags, Schlagwörter, Einstellungen, Bankabruf anstossen. Hier gehört auch das Einrichten des zweiten Faktors hin — bisher verweist die App dafür in den Browser.

Stufe 6 — Erweiterungen. Teilen-Menü und Kurzbefehle. Brauchen eigene Ziele im Xcode-Projekt, eine App-Gruppe und eine geteilte Schlüsselbund-Gruppe; Netzwerk/Ablage.swift hat dafür schon die beiden Konstanten gruppe und schluesselbundGruppe (stehen auf nil). Für die Kurzbefehle ist POST /api/quick/booking fertig: Header-Marke, idempotent über external_id, fertig formulierte Antwort-Meldung.

Stufe 7 — Import, voll. Erkennen, Rohansicht, Spaltenzuordnung, Formateinstellungen, Zeilenfilter, editierbare Prüfansicht, Saldo-Abgleich, Vorlagen, Verlauf mit Rücknahme.

Zum Umfang, ehrlich

Die Weboberfläche hat rund 8900 Zeilen JSX. Nativ wird das nicht weniger. Stufen 2 bis 6 zusammen sind grob drei bis vier Wochen, Stufe 7 nochmal zwei bis drei.

Stufe 7 steht bewusst am Ende. Alles davor ist für sich nutzbar. Der Ablauf hat keinen Server-Zustand zwischen den Schritten: Die Datei wird bei jedem Schritt neu hochgeladen, und beim Buchen gehen die Vorschauzeilen unverändert zurück an den Server. Eine editierbare Prüftabelle mit 300 Zeilen und neun Spaltenzuordnungen ist auf Telefonbreite ein Entwurfsproblem, kein Übersetzungsproblem. Das Backend selbst sagt dazu in backend/app/routers/quick.py: „Ein Auszug hat schnell hunderte Zeilen; die am Telefon durchzusehen wäre keine Erleichterung." Der Nutzer kennt diesen Einwand und will den vollen Import trotzdem.


7. Fallen und Arbeitsweise

Beim Übersetzen am ehesten verdächtig

Nach abnehmender Wahrscheinlichkeit:

  1. @testable import Certain in Tests/GrundlagenTests.swift muss auf den Namen des App-Ziels zeigen.
  2. Lader ist eine generische @Observable-Klasse — die ungewöhnlichste Konstruktion im Code. Wird bisher nur in KontenListe benutzt.
  3. Nebenläufigkeit: AppZustand ist @MainActor, CertainClient ein Actor. An den Übergängen kann es unter strikter Prüfung Warnungen setzen.
  4. Tab und .tabViewStyle(.sidebarAdaptable) setzen iOS 18 voraus — bei iOS 26 als Mindestversion kein Problem.
  5. Die Tests nutzen Swift Testing (import Testing, @Test, #expect), nicht XCTest.

Schreibende Aufrufe mit Query statt Körper

Leicht zu übersehen, das Backend macht es an mehreren Stellen: PUT /budget/template/category/{id}, PUT /budget/{label}/category/{id}, PUT /settings/{key}, POST /subscriptions/{id}/preis.

Konventionen des Projekts

  • Alles auf Deutsch: Code-Kommentare, Bezeichner, Testnamen, Commit- Meldungen, Oberflächentexte. Testnamen sind deutsche Aussagesätze, ASCII-transliteriert (test_app_marke_darf_lesen_und_schreiben).
  • Commits: typ: Wirkung für den Nutzer, auf Deutsch, eine Wirkung je Commit. Fertige Arbeit wird committet und gepusht, ohne Rückfrage.
  • Kommentare erklären das Warum, nicht das Was — und zwar das, was der Code nicht zeigen kann. Das ganze Projekt ist so geschrieben; halte es durch.
  • Tests: cd backend && ./.venv/Scripts/python.exe -m pytest tests/ -q (Python 3.12 im venv; das System-Python kann nicht bauen). Dauert rund zwei Minuten — im Hintergrund laufen lassen.
  • Vorbekannt rot, nicht von dir verursacht: test_mail_import.py::test_von_hand_weitergeleitete_mail_wird_am_betreff_erkannt und test_migrations.py::test_batch_migration_keeps_dependent_rows (der scheitert an einem Umgebungsproblem, WinError 10106 in einem Subprozess). Erwartung nach jedem Lauf: genau diese zwei, sonst nichts.
  • Heredocs in Bash scheitern an den deutschen Anführungszeichen („ "). Für Dateien mit solchen Zeichen das Write-Werkzeug nehmen, nicht cat <<EOF.

Offene Punkte, die der Nutzer bestimmt

  • Name, Bundle-ID und Signierung — er legt das Projekt an.
  • Ob die Benutzerverwaltung doch mit hinein soll.
  • Ob eine neu vergebene App-Marke am Rechner bestätigt werden muss, bevor sie gilt. Sicherer, aber umständlich beim Einrichten eines neuen Geräts.