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>
356 lines
16 KiB
Markdown
356 lines
16 KiB
Markdown
# Ü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.
|