diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9bfcaee..d64dd9c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -43,3 +43,15 @@ künftigen Importen. Korrektur-Erkennung: ab 5 Anwendungen und ≥ 1/3 Korrektur Prüf-Meldung in den Einstellungen (Behalten/Entfernen). Auf-/zuklappbares Kategorie-Dropdown mit Suche (ersetzt das native optgroup-Select) in Buchungen und Import-Prüfansicht. + +## Phase 6 – iOS-App 🚧 +Native App in `ios/`, die sich über die Server-Adresse ans selbe Backend hängt. +Anmeldung über eine Gerätemarke der Reichweite `app` (kein Passwort auf dem +Gerät, kein 30-Tage-Ablauf, einzeln widerrufbar). Deutsch und Englisch, iPad +zweispaltig, offline der letzte Stand mit Datum. + +Stufe 1 ✅: Gerüst, Netzwerkschicht, Anmeldung samt zweitem Faktor und +Pflicht-Passwortwechsel, Kontenliste. Stufen 2–7 (Lesen, Schreiben, +Arbeitsliste, Nebenräume, Erweiterungen, voller CSV-Import) stehen aus. + +**Stand, Entscheidungen und offene Punkte: `ios/UEBERGABE.md`.** diff --git a/ios/README.md b/ios/README.md index 7df8a0b..579a5c6 100644 --- a/ios/README.md +++ b/ios/README.md @@ -4,6 +4,11 @@ Native App zum Certain-Backend. Der Code hier ist **nie übersetzt worden** — der Maschine, auf der er entstanden ist, gibt es kein Xcode. Rechne beim ersten Bauen mit Fehlern und schick sie mir; das ist eingeplant. +> **Wer hier weiterarbeitet, liest zuerst [UEBERGABE.md](UEBERGABE.md).** Dort +> steht, was entschieden ist, was fertig ist, was als Nächstes dran ist und +> welche Eigenheiten das Backend hat. Dieses README erklärt nur, wie man den +> Code ins Projekt bekommt. + ## Projekt anlegen 1. Xcode → neues Projekt → **App**, Schnittstelle **SwiftUI**, Sprache **Swift**. diff --git a/ios/UEBERGABE.md b/ios/UEBERGABE.md new file mode 100644 index 0000000..92551a7 --- /dev/null +++ b/ios/UEBERGABE.md @@ -0,0 +1,355 @@ +# Ü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 <