Compare commits

29 Commits
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
76 changed files with 13796 additions and 39 deletions
+4
View File
@@ -21,3 +21,7 @@ deploy/.env
.DS_Store
.vscode/
.idea/
# Xcode
**/xcuserdata/
DerivedData/
+57 -1
View File
@@ -1,9 +1,15 @@
"""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
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
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
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt
@@ -54,7 +60,57 @@ def _session_user(request: Request, db: Session) -> User | None:
return request.state.session_user
token = request.cookies.get(get_settings().cookie_name, "")
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.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
+17 -7
View File
@@ -409,7 +409,7 @@ class CategoryGroup(Base):
name: Mapped[str] = mapped_column(String(120), nullable=False)
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
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)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -431,7 +431,7 @@ class Category(Base):
name: Mapped[str] = mapped_column(String(120), nullable=False)
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
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)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -813,14 +813,24 @@ class CategorizationRule(Base):
class TokenScope(str, enum.Enum):
"""Was eine Gerätemarke darf. Bewusst eng gehalten.
"""Was eine Gerätemarke darf.
Eine 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.
``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):
@@ -1302,7 +1312,7 @@ class Tag(Base):
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="#6b7480")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
+13 -1
View File
@@ -46,7 +46,19 @@ def get_template(db: Session = Depends(get_db), user: User = Depends(get_current
.all()
)
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],
}
+9 -3
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..deps import get_current_user, get_owned, require_cookie_session
from ..models import (
Account,
ApiToken,
@@ -45,9 +45,14 @@ def list_tokens(db: Session = Depends(get_db), user: User = Depends(get_current_
def create_token(
payload: ApiTokenCreate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
user: User = Depends(require_cookie_session),
):
"""Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen."""
"""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()
@@ -55,6 +60,7 @@ def create_token(
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)
+40 -11
View File
@@ -9,7 +9,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import select, union
from sqlalchemy import or_, select, union
from sqlalchemy.orm import Session
from ..database import get_db
@@ -194,18 +194,24 @@ def list_transactions(
type: TxnType | None = None,
date_from: date | None = None,
date_to: date | None = None,
# 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,
):
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:
q = q.filter(
abfrage = abfrage.filter(
(Transaction.account_id == account_id) | (Transaction.to_account_id == account_id)
)
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,
@@ -214,16 +220,39 @@ def list_transactions(
select(TransferLink.from_txn_id).where(TransferLink.owner_id == user.id),
select(TransferLink.to_txn_id).where(TransferLink.owner_id == user.id),
)
q = q.filter(Transaction.category_id.is_(None),
Transaction.type != TxnType.transfer,
Transaction.id.notin_(verknuepft))
abfrage = abfrage.filter(Transaction.category_id.is_(None),
Transaction.type != TxnType.transfer,
Transaction.id.notin_(verknuepft))
if type is not None:
q = q.filter(Transaction.type == type)
abfrage = abfrage.filter(Transaction.type == type)
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:
q = q.filter(Transaction.date <= date_to)
rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
abfrage = abfrage.filter(Transaction.date <= date_to)
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)
ccy = _currency_map(db, user)
woher = _herkunft(db, user, rows)
+8 -3
View File
@@ -15,6 +15,7 @@ from .models import (
IdentifierKind,
ImportStatus,
Role,
TokenScope,
TxnType,
)
@@ -360,7 +361,7 @@ class TagOut(BaseModel):
class TagCreate(BaseModel):
name: str = Field(min_length=1, max_length=60)
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
color: str = "#6b7480"
color: str = "#3f9c72"
class TagUpdate(BaseModel):
@@ -405,7 +406,7 @@ class CategoryGroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
kind: CategoryKind = CategoryKind.expense
icon: str = "list"
color: str = "#6b7480"
color: str = "#3f9c72"
class CategoryGroupUpdate(BaseModel):
@@ -419,7 +420,7 @@ class CategoryCreate(BaseModel):
group_id: int
name: str = Field(min_length=1, max_length=120)
icon: str = "more-horizontal"
color: str = "#6b7480"
color: str = "#3f9c72"
excluded_from_stats: bool = False
spending_is_good: bool = False
@@ -687,11 +688,15 @@ class FlaggedKeywordOut(BaseModel):
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
+27 -7
View File
@@ -217,13 +217,27 @@ def reset_month(db: Session, user: User, label: str) -> None:
def copy_month_to_template(db: Session, user: User, label: str) -> None:
"""Setzt die Standard-Vorlage auf die wirksamen Soll-Werte dieses Monats
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt."""
planned = effective_planned(db, user, label) # {category_id: cents}
"""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 category_id, cents in planned.items():
if cents > 0:
db.add(BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=cents))
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()
@@ -241,7 +255,13 @@ def copy_template_to_month(db: Session, user: User, label: str) -> BudgetPeriod:
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))
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
+15 -4
View File
@@ -310,6 +310,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
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:
@@ -321,17 +332,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
gegen = _eindeutig(a, offen, kandidaten, verbraucht)
if gegen is None:
if offen:
vorschlaege.append((a, offen[0]))
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:
vorschlaege.append((a, gegen))
schlage_vor(a, gegen)
continue
if not _hat_hinweis(a, gegen, bezeichner, typen):
vorschlaege.append((a, gegen))
schlage_vor(a, gegen)
continue
if nur_vorschlaege:
vorschlaege.append((a, gegen))
schlage_vor(a, gegen)
continue
verbinde(db, user, a, gegen, automatisch=True)
verbraucht.update((a.id, gegen.id))
+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())
+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
+126
View File
@@ -0,0 +1,126 @@
"""Suche und Filter auf der Buchungsliste: ``q``, ``tag_id``, ``subscription_id``.
Bisher siebte die Weboberflaeche im Browser und lud dafuer alles, was es gab -
das Limit steht nicht umsonst auf 5000. Am Telefon ist das der Unterschied
zwischen "laedt" und "benutzbar", deshalb sitzen die drei Filter jetzt im
Server.
Die Datenbank lebt ueber den ganzen Testlauf. Texte deshalb eindeutig
wuerfeln, sonst findet die Suche die Daten anderer Tests mit.
"""
import secrets
from conftest import login_ready, new_user
def _konto(admin):
return admin.post("/api/accounts", json={
"name": f"Konto {secrets.token_hex(3)}", "type": "girokonto",
"currency": "EUR", "opening_balance_cents": 0}).json()
def _buchung(admin, konto, **felder):
r = admin.post("/api/transactions", json={
"type": "expense", "date": "2026-09-15", "account_id": konto["id"],
"amount_cents": 100 + secrets.randbelow(9000), **felder})
assert r.status_code == 201, r.text
return r.json()
def _suche(admin, **filter_):
r = admin.get("/api/transactions", params=filter_)
assert r.status_code == 200, r.text
return [t["id"] for t in r.json()]
def test_suche_findet_empfaenger_verwendungszweck_und_notiz(admin):
konto = _konto(admin)
wort = f"Zebra{secrets.token_hex(4)}"
ueber_empfaenger = _buchung(admin, konto, payee=f"Laden {wort}")
ueber_zweck = _buchung(admin, konto, payee="Egal", description=f"Rechnung {wort}")
ueber_notiz = _buchung(admin, konto, payee="Egal", notes=f"wegen {wort}")
daneben = _buchung(admin, konto, payee="Etwas voellig anderes")
gefunden = _suche(admin, q=wort)
assert set(gefunden) == {ueber_empfaenger["id"], ueber_zweck["id"], ueber_notiz["id"]}
assert daneben["id"] not in gefunden
def test_suche_achtet_nicht_auf_gross_und_kleinschreibung(admin):
konto = _konto(admin)
wort = f"Kiosk{secrets.token_hex(4)}"
txn = _buchung(admin, konto, payee=wort.upper())
assert txn["id"] in _suche(admin, q=wort.lower())
def test_prozentzeichen_im_suchtext_bleibt_ein_zeichen(admin):
"""Sonst waere jede Eingabe mit % ein Platzhalter und faende alles."""
konto = _konto(admin)
marke = secrets.token_hex(4)
mit_prozent = _buchung(admin, konto, payee=f"Rabatt%{marke}")
ohne = _buchung(admin, konto, payee=f"Rabatt {marke}")
gefunden = _suche(admin, q=f"Rabatt%{marke}")
assert gefunden == [mit_prozent["id"]]
assert ohne["id"] not in gefunden
def test_tag_filter_zeigt_nur_buchungen_mit_diesem_tag(admin):
konto = _konto(admin)
tag = admin.post("/api/tags", json={
"name": f"Tag {secrets.token_hex(4)}", "color": "#3f51b5"}).json()
mit = _buchung(admin, konto, payee="Mit Etikett")
ohne = _buchung(admin, konto, payee="Ohne Etikett")
admin.patch(f"/api/transactions/{mit['id']}", json={"tag_ids": [tag["id"]]})
gefunden = _suche(admin, tag_id=tag["id"])
assert gefunden == [mit["id"]]
assert ohne["id"] not in gefunden
def test_tag_eines_fremden_nutzers_zeigt_keine_buchungen(admin):
"""Mandanten-Isolation: Eine geratene fremde ID darf nichts aufschliessen."""
konto = _konto(admin)
meine = _buchung(admin, konto, payee="Meine Buchung")
name, passwort = new_user(admin)
fremd, _ = login_ready(name, passwort)
fremder_tag = fremd.post("/api/tags", json={
"name": f"Fremd {secrets.token_hex(4)}", "color": "#eb6834"}).json()
gefunden = _suche(admin, tag_id=fremder_tag["id"])
assert gefunden == []
assert meine["id"] not in gefunden
def test_abo_filter_zeigt_nur_buchungen_dieses_vertrags(admin):
konto = _konto(admin)
abo = admin.post("/api/subscriptions", json={
"name": f"Dienst {secrets.token_hex(4)}", "amount_cents": 999,
"currency": "EUR", "interval_months": 1}).json()
zugeordnet = _buchung(admin, konto, payee="Monatsbeitrag")
frei = _buchung(admin, konto, payee="Einmalkauf")
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{zugeordnet['id']}")
assert r.status_code == 200, r.text
gefunden = _suche(admin, subscription_id=abo["id"])
assert gefunden == [zugeordnet["id"]]
assert frei["id"] not in gefunden
def test_suche_und_tag_filter_greifen_zusammen(admin):
konto = _konto(admin)
wort = f"Baumarkt{secrets.token_hex(4)}"
tag = admin.post("/api/tags", json={
"name": f"Tag {secrets.token_hex(4)}", "color": "#2a78d6"}).json()
beides = _buchung(admin, konto, payee=f"{wort} Farbe")
nur_text = _buchung(admin, konto, payee=f"{wort} Schrauben")
nur_tag = _buchung(admin, konto, payee="Etwas anderes")
for txn in (beides, nur_tag):
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
gefunden = _suche(admin, q=wort, tag_id=tag["id"])
assert gefunden == [beides["id"]]
assert nur_text["id"] not in gefunden and nur_tag["id"] not in gefunden
+93
View File
@@ -369,3 +369,96 @@ def test_aufstellung_zeigt_eine_verschobene_buchung_im_neuen_monat(admin):
assert not any(r["id"] == tx["id"] for r in auf["rows"])
karte2 = admin.get("/api/dashboard/expense-share?group_by=category").json()
assert auf["total"] == next(r for r in karte2["rows"] if r["name"] == "Lebensmittel")["amount"]
def test_vorlage_nennt_betriebsart_und_zuschlag(admin):
"""Die Vorlage gab bisher nur planned_cents heraus.
Wer sie so liest, haelt eine berechnete Zeile fuer eine feste - und
schreibt sie beim naechsten Speichern auch als feste zurueck. PUT und die
Monatsansicht nennen beide Felder laengst; hier fehlten sie schlicht.
Eine eigene Kategorie statt Lebensmittel: Die Testdatenbank lebt ueber den
ganzen Lauf, und eine Vorlagenzeile auf "berechnet" umzustellen wuerde die
Nachbartests treffen.
"""
import secrets
gruppe = next(g for g in admin.get("/api/categories").json()
if g["kind"] == "expense")
kat = admin.post("/api/categories", json={
"group_id": gruppe["id"], "name": f"Vorlagenart {secrets.token_hex(3)}"}).json()
r = admin.put(f"/api/budget/template/category/{kat['id']}",
params={"planned_cents": 12000, "modus": "berechnet",
"zuschlag_cents": 2500})
assert r.status_code == 200, r.text
zeile = next(l for l in admin.get("/api/budget/template").json()["lines"]
if l["category_id"] == kat["id"])
assert zeile["planned_cents"] == 12000
assert zeile["modus"] == "berechnet"
assert zeile["zuschlag_cents"] == 2500
def _frische_kategorie(admin, name):
import secrets
gruppe = next(g for g in admin.get("/api/categories").json()
if g["kind"] == "expense")
return admin.post("/api/categories", json={
"group_id": gruppe["id"], "name": f"{name} {secrets.token_hex(3)}"}).json()["id"]
def test_berechnete_zeile_ueberlebt_den_weg_in_den_monat(admin):
"""Vorlage -> Monat darf die Betriebsart nicht verlieren.
Fiel die Zeile auf "fest" zurueck, stand als Soll die Zahl, die zufaellig
zuletzt in planned_cents lag - und der Bezug zu den Vertraegen war weg,
ohne dass irgendwo stand, dass er weg ist.
"""
today = date.today()
label = f"{today.year:04d}-{today.month:02d}"
kat = _frische_kategorie(admin, "Berechnet-Monat")
admin.put(f"/api/budget/template/category/{kat}",
params={"planned_cents": 0, "modus": "berechnet", "zuschlag_cents": 1500})
r = admin.post(f"/api/budget/{label}/from-template")
assert r.status_code == 200, r.text
zeile = next(c for g in r.json()["groups"] for c in g["categories"]
if c["category_id"] == kat)
assert zeile["modus"] == "berechnet"
assert zeile["zuschlag_cents"] == 1500
def test_berechnete_zeile_ueberlebt_den_weg_in_die_vorlage(admin):
"""Monat -> Vorlage ebenso: "diesen Monat als Standard festlegen" soll die
Betriebsart uebernehmen, nicht die gerade herauskommende Zahl einfrieren."""
today = date.today()
label = f"{today.year:04d}-{today.month:02d}"
kat = _frische_kategorie(admin, "Berechnet-Vorlage")
admin.put(f"/api/budget/{label}/category/{kat}",
params={"planned_cents": 0, "modus": "berechnet", "zuschlag_cents": 2500})
r = admin.post(f"/api/budget/template/from-month/{label}")
assert r.status_code == 200, r.text
zeile = next(l for l in r.json()["lines"] if l["category_id"] == kat)
assert zeile["modus"] == "berechnet"
assert zeile["zuschlag_cents"] == 2500
def test_eine_feste_null_landet_nicht_in_der_vorlage(admin):
"""Nichts Geplantes bleibt nichts Geplantes - sonst fuellte sich die
Vorlage mit Nullzeilen fuer jede je beruehrte Kategorie."""
today = date.today()
label = f"{today.year:04d}-{today.month:02d}"
kat = _frische_kategorie(admin, "Nullzeile")
admin.put(f"/api/budget/{label}/category/{kat}", params={"planned_cents": 0})
r = admin.post(f"/api/budget/template/from-month/{label}")
assert all(l["category_id"] != kat for l in r.json()["lines"])
+27
View File
@@ -354,3 +354,30 @@ def test_vorschlag_auch_wenn_die_gegenseite_tage_spaeter_steht(admin):
# Bestaetigen kann der Nutzer es trotzdem.
assert admin.post("/api/uebertraege/verbinden",
json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200
def test_ein_paar_wird_nur_einmal_vorgeschlagen(admin):
"""Jedes Paar steht intern zweimal, einmal je Richtung.
Ohne Entdopplung saehe der Nutzer dieselbe Zahlung zweimal zum Verbinden
angeboten - und wer das zweite anklickt, bekaeme den Fehler "bereits
verknuepft" fuer etwas, das er gerade selbst verknuepft hat.
"""
import secrets
betrag = 100000 + secrets.randbelow(800000)
a = admin.post("/api/accounts", json={
"name": f"A {secrets.token_hex(3)}", "currency": "EUR"}).json()
b = admin.post("/api/accounts", json={
"name": f"B {secrets.token_hex(3)}", "currency": "EUR"}).json()
raus = admin.post("/api/transactions", json={
"type": "expense", "date": "2026-07-14", "account_id": a["id"],
"amount_cents": betrag, "payee": "Umbuchung"}).json()
rein = admin.post("/api/transactions", json={
"type": "income", "date": "2026-07-14", "account_id": b["id"],
"amount_cents": betrag, "payee": "Umbuchung"}).json()
paare = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"]
meins = [p for p in paare
if {p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]}]
assert len(meins) == 1, f"einmal vorschlagen, nicht {len(meins)}-mal"
+20
View File
@@ -43,3 +43,23 @@ 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. Stufe 2 ✅: Übersicht, Budget-Monat,
Buchungsliste mit Suche, Vertragsliste. Stufe 3 ✅: Buchungen, Budget-Zeilen,
Verträge und Konten anlegen, ändern und löschen. Stufe 4 ✅: Arbeitsliste mit
Eingang, Übertragsvorschlägen, Gebuchtem ohne Kategorie und Dubletten.
Stufe 5 ✅: Kategorien samt Erkennungswörtern, Schlagwörter, Einstellungen,
Bankabruf und das Einrichten des zweiten Faktors.
Übersetzt fehlerfrei, 59 Tests grün, läuft im Simulator; das Xcode-Projekt
entsteht aus `ios/project.yml`, die Modelle werden gegen echte
Serverantworten geprüft.
Stufen 6–7 (Teilen-Menü und Kurzbefehle, voller CSV-Import) stehen aus.
**Stand, Entscheidungen und offene Punkte: `ios/UEBERGABE.md`.**
+201
View File
@@ -0,0 +1,201 @@
import SwiftUI
/// Einen Vertrag anlegen oder ändern.
struct AboFormular: View {
enum Zweck: Equatable, Identifiable {
case neu
case aendern(Abo)
var id: Int {
switch self {
case .neu: return 0
case .aendern(let abo): return abo.id
}
}
}
let zweck: Zweck
var fertig: () -> Void
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
@State private var vorgang = Speichervorgang()
@State private var name = ""
@State private var betragCents = 0
@State private var waehrung = "EUR"
@State private var rhythmus: Zahlungsrhythmus = .monatlich
@State private var kategorieId: Int?
@State private var kontoId: Int?
@State private var hatFaelligkeit = false
@State private var faelligAm = Date()
@State private var erkennungstext = ""
@State private var notiz = ""
@State private var istAktiv = true
@State private var symbol = "refresh-ccw"
@State private var farbe = "#6b7480"
@State private var fragtNachLoeschen = false
@State private var vorbelegt = false
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(localized: "abo.name"), text: $name)
Betragsfeld(titel: String(localized: "abo.betrag"),
waehrung: waehrung, cents: $betragCents)
Picker(String(localized: "abo.rhythmus"), selection: $rhythmus) {
ForEach(Zahlungsrhythmus.allCases) { einer in
Text(einer.name).tag(einer)
}
}
}
Section {
KategorieFeld(titel: String(localized: "buchung.kategorie"),
nachschlag: zustand.nachschlag, art: .expense,
auswahl: $kategorieId)
KontoFeld(titel: String(localized: "abo.konto"),
nachschlag: zustand.nachschlag, auswahl: $kontoId)
} footer: {
Text(String(localized: "abo.kategorieErklaerung"))
}
Section {
Toggle(String(localized: "abo.faelligkeitKennen"), isOn: $hatFaelligkeit)
if hatFaelligkeit {
DatePicker(String(localized: "abo.naechsteZahlung"),
selection: $faelligAm, displayedComponents: .date)
}
}
Section {
TextField(String(localized: "abo.erkennungstext"), text: $erkennungstext)
} footer: {
Text(String(localized: "abo.erkennungErklaerung"))
}
Section {
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
TextField(String(localized: "buchung.notiz"), text: $notiz, axis: .vertical)
.lineLimit(1...3)
}
if case .aendern = zweck {
Section {
Toggle(String(localized: "abo.laeuftNoch"), isOn: $istAktiv)
Button(String(localized: "abo.loeschen"), role: .destructive) {
fragtNachLoeschen = true
}
} footer: {
Text(String(localized: "abo.beendenStattLoeschen"))
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.sichern")) {
Task { await sichere() }
}
.disabled(!istVollstaendig || vorgang.laeuft)
}
}
.interactiveDismissDisabled(vorgang.laeuft)
.confirmationDialog(String(localized: "abo.loeschenFrage"),
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
Button(String(localized: "abo.loeschen"), role: .destructive) {
Task { await loesche() }
}
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
} message: {
Text(String(localized: "abo.loeschenErklaerung"))
}
.onAppear(perform: belegeVor)
}
}
private var titel: String {
switch zweck {
case .neu: return String(localized: "abo.neu")
case .aendern: return String(localized: "abo.aendern")
}
}
private var istVollstaendig: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && betragCents > 0
}
private func belegeVor() {
guard !vorbelegt else { return }
vorbelegt = true
guard case .aendern(let abo) = zweck else {
waehrung = zustand.einstellungen.basiswaehrung
return
}
name = abo.name
betragCents = abo.amountCents
waehrung = abo.currency
rhythmus = Zahlungsrhythmus(rawValue: abo.intervalMonths) ?? .monatlich
kategorieId = abo.categoryId
kontoId = abo.accountId
hatFaelligkeit = abo.nextDue != nil
faelligAm = abo.nextDue?.datum ?? Date()
erkennungstext = abo.payeePattern ?? ""
notiz = abo.notes ?? ""
istAktiv = abo.isActive
symbol = abo.icon
farbe = abo.color
}
private func sichere() async {
guard let client = zustand.client else { return }
let faellig = hatFaelligkeit ? Tagesdatum(faelligAm) : nil
let sauberName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let geglueckt = await vorgang.fuehreAus {
switch zweck {
case .neu:
_ = try await client.legeAboAn(AboAnlegen(
name: sauberName, icon: symbol, color: farbe,
amountCents: betragCents, currency: waehrung,
intervalMonths: rhythmus.rawValue, categoryId: kategorieId,
accountId: kontoId, nextDue: faellig,
payeePattern: leerAlsNil(erkennungstext), notes: leerAlsNil(notiz)))
case .aendern(let abo):
_ = try await client.aendereAbo(abo.id, AboAendern(
name: sauberName, icon: symbol, color: farbe,
amountCents: betragCents, intervalMonths: rhythmus.rawValue,
categoryId: kategorieId, accountId: kontoId, nextDue: faellig,
payeePattern: leerAlsNil(erkennungstext), isActive: istAktiv,
notes: leerAlsNil(notiz)))
}
}
if geglueckt {
fertig()
schliesse()
}
}
private func loesche() async {
guard let client = zustand.client, case .aendern(let abo) = zweck else { return }
if await vorgang.fuehreAus({ try await client.loescheAbo(abo.id) }) {
fertig()
schliesse()
}
}
private func leerAlsNil(_ text: String) -> String? {
let sauber = text.trimmingCharacters(in: .whitespacesAndNewlines)
return sauber.isEmpty ? nil : sauber
}
}
+172
View File
@@ -0,0 +1,172 @@
import SwiftUI
/// Verträge und Abos: was jeden Monat von selbst abgeht.
struct AboListe: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<Aboliste>?
@State private var zeigeBeendete = false
@State private var formular: AboFormular.Zweck?
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { liste in
inhalt(liste)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "reiter.abos"))
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle(String(localized: "abos.beendeteZeigen"), isOn: $zeigeBeendete)
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button { formular = .neu } label: { Image(systemName: "plus") }
.accessibilityLabel(String(localized: "abo.neu"))
}
}
.sheet(item: $formular) { zweck in
AboFormular(zweck: zweck) { Task { await lader?.aktualisiere() } }
.environment(zustand)
}
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<Aboliste>(schluessel: "subscriptions") {
try await client.abos()
}
lader = neuer
await neuer.lade()
}
}
@ViewBuilder
private func inhalt(_ liste: Aboliste) -> some View {
let sichtbar = liste.subscriptions.filter { zeigeBeendete || $0.isActive }
if sichtbar.isEmpty {
LeerAnsicht(titel: String(localized: "abos.leerTitel"),
erklaerung: String(localized: "abos.leerErklaerung"),
symbol: "arrow.triangle.2.circlepath")
} else {
List {
Section {
SummenKopf(summen: liste.summary)
}
Section {
ForEach(sichtbar) { abo in
Button {
formular = .aendern(abo)
} label: {
AboZeile(abo: abo, nachschlag: zustand.nachschlag)
}
.buttonStyle(.plain)
}
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
}
}
}
private struct SummenKopf: View {
let summen: Abosummen
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(summen.monatlich.formatiert())
.font(.title.weight(.semibold).monospacedDigit())
Text(String(localized: "abo.monatlich"))
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text(String(format: String(localized: "abos.jaehrlichUndAnzahl"),
summen.jaehrlich.formatiert(), summen.anzahlAktiv))
.font(.caption)
.foregroundStyle(.secondary)
if !summen.ohneKurs.isEmpty {
// Ohne diesen Hinweis ist die Summe still zu klein, und
// niemand sieht, dass etwas fehlt.
Label {
Text(String(format: String(localized: "abos.ohneKurs"),
summen.ohneKurs.joined(separator: ", ")))
} icon: {
Image(systemName: "exclamationmark.triangle")
}
.font(.caption)
.foregroundStyle(.orange)
}
}
.padding(.vertical, 4)
}
}
private struct AboZeile: View {
let abo: Abo
let nachschlag: Nachschlag
var body: some View {
HStack(spacing: 12) {
Symbolplakette(symbol: abo.icon, farbe: abo.color, groesse: 36)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(abo.name)
.font(.body)
.lineLimit(1)
if !abo.isActive {
Text(String(localized: "abos.beendet"))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
}
Text(unterzeile)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(abo.betrag.formatiert())
.font(.body.monospacedDigit())
// Nur dort nennen, wo es etwas sagt: Bei einem monatlichen
// Vertrag stuende zweimal derselbe Betrag.
if abo.intervalMonths != 1 {
Text(String(format: String(localized: "abo.proMonat"),
abo.monatlich.formatiert()))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 2)
}
private var unterzeile: String {
var teile = [abo.rhythmus]
if let kategorie = nachschlag.kategorie(abo.categoryId) {
teile.append(kategorie.name)
}
if abo.isActive, let faellig = abo.nextDue {
if let tage = abo.tageBisFaellig, tage <= 7 {
teile.append(String(format: String(localized: "abo.inTagen"), tage))
} else {
teile.append(String(format: String(localized: "abo.naechste"), faellig.formatiert()))
}
}
return teile.joined(separator: " · ")
}
}
+79
View File
@@ -0,0 +1,79 @@
import SwiftUI
/// Benutzername und Passwort – einmal. Danach trägt die Gerätemarke, und
/// das Passwort wird nirgends gespeichert.
struct AnmeldeAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var benutzer = Ablage.benutzername ?? ""
@State private var passwort = ""
@FocusState private var feld: Feld?
private enum Feld { case benutzer, passwort }
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(localized: "anmeldung.benutzer"), text: $benutzer)
.textContentType(.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($feld, equals: .benutzer)
.submitLabel(.next)
.onSubmit { feld = .passwort }
SecureField(String(localized: "anmeldung.passwort"), text: $passwort)
.textContentType(.password)
.focused($feld, equals: .passwort)
.submitLabel(.go)
.onSubmit { melde() }
} footer: {
Text(String(localized: "anmeldung.erklaerung"))
}
if let meldung = zustand.meldung {
Section {
Label(meldung, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
Section {
Button(action: melde) {
if zustand.beschaeftigt {
ProgressView()
} else {
Text(String(localized: "anmeldung.anmelden"))
}
}
.disabled(bereit == false || zustand.beschaeftigt)
}
Section {
Button(String(localized: "anmeldung.andererServer"), role: .destructive) {
Task { await zustand.meldeAb(adresseBehalten: false) }
}
} footer: {
if let adresse = zustand.serverAdresse {
Text(String(format: String(localized: "anmeldung.aktuellerServer"),
adresse.host() ?? adresse.absoluteString))
}
}
}
.navigationTitle(String(localized: "anmeldung.titel"))
.onAppear { feld = benutzer.isEmpty ? .benutzer : .passwort }
}
}
private var bereit: Bool {
!benutzer.trimmingCharacters(in: .whitespaces).isEmpty && !passwort.isEmpty
}
private func melde() {
guard bereit else { return }
let name = benutzer.trimmingCharacters(in: .whitespaces)
let geheim = passwort
passwort = ""
Task { await zustand.melde(benutzer: name, passwort: geheim) }
}
}
@@ -0,0 +1,93 @@
import SwiftUI
/// Pflicht-Passwortwechsel. Solange der offen ist, sperrt das Backend jeden
/// fachlichen Endpunkt mit 403 – auch das Anlegen der Gerätemarke. Deshalb
/// steht dieser Bildschirm vor allem anderen.
///
/// Die Richtlinie des Servers (Mindestlänge, Zeichenarten) prüfen wir hier
/// nicht nach. Das Backend kennt sie, und sie doppelt zu pflegen hiesse, sie
/// irgendwann unterschiedlich zu haben. Es meldet den Grund im Klartext.
struct PasswortWechselAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var alt = ""
@State private var neu = ""
@State private var wiederholung = ""
@FocusState private var feld: Feld?
private enum Feld { case alt, neu, wiederholung }
var body: some View {
NavigationStack {
Form {
Section {
SecureField(String(localized: "passwort.bisher"), text: $alt)
.textContentType(.password)
.focused($feld, equals: .alt)
.submitLabel(.next)
.onSubmit { feld = .neu }
} footer: {
Text(String(localized: "passwort.erklaerung"))
}
Section {
SecureField(String(localized: "passwort.neu"), text: $neu)
.textContentType(.newPassword)
.focused($feld, equals: .neu)
.submitLabel(.next)
.onSubmit { feld = .wiederholung }
SecureField(String(localized: "passwort.wiederholen"), text: $wiederholung)
.textContentType(.newPassword)
.focused($feld, equals: .wiederholung)
.submitLabel(.go)
.onSubmit { wechsle() }
} footer: {
if !wiederholung.isEmpty, neu != wiederholung {
Text(String(localized: "passwort.stimmtNichtUeberein"))
.foregroundStyle(.red)
}
}
if let meldung = zustand.meldung {
Section {
Label(meldung, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
Section {
Button(action: wechsle) {
if zustand.beschaeftigt {
ProgressView()
} else {
Text(String(localized: "passwort.speichern"))
}
}
.disabled(bereit == false || zustand.beschaeftigt)
}
Section {
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
Task { await zustand.meldeAb() }
}
}
}
.navigationTitle(String(localized: "passwort.titel"))
.onAppear { feld = .alt }
}
}
private var bereit: Bool {
!alt.isEmpty && !neu.isEmpty && neu == wiederholung
}
private func wechsle() {
guard bereit else { return }
let bisher = alt
let frisch = neu
Task {
await zustand.wechslePasswort(alt: bisher, neu: frisch)
alt = ""; neu = ""; wiederholung = ""
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import SwiftUI
/// Der allererste Bildschirm: Wo steht dein Certain?
///
/// Die Adresse wird nicht blind übernommen, sondern mit `GET /api/version`
/// geprüft. Das erspart die Sorte Fehlersuche, bei der man sich eine Stunde
/// fragt, warum die Anmeldung nicht geht, und am Ende ein Buchstabe fehlte.
struct ServerAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var eingabe = ""
@FocusState private var imFeld: Bool
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(localized: "server.platzhalter"), text: $eingabe)
.textContentType(.URL)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($imFeld)
.submitLabel(.go)
.onSubmit { pruefe() }
} header: {
Text(String(localized: "server.ueberschrift"))
} footer: {
Text(String(localized: "server.erklaerung"))
}
if let meldung = zustand.meldung {
Section {
Label(meldung, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
Section {
Button(action: pruefe) {
if zustand.beschaeftigt {
ProgressView()
} else {
Text(String(localized: "server.verbinden"))
}
}
.disabled(eingabe.trimmingCharacters(in: .whitespaces).isEmpty
|| zustand.beschaeftigt)
}
}
.navigationTitle(String(localized: "server.titel"))
.onAppear { imFeld = true }
}
}
private func pruefe() {
Task { _ = await zustand.pruefeUndMerkeServer(eingabe) }
}
}
+210
View File
@@ -0,0 +1,210 @@
import LocalAuthentication
import SwiftUI
/// Der Sperrbildschirm. Face ID zuerst, die PIN als Rückfallweg – und als
/// einziger Weg, wenn das Gerät keine Biometrie hat oder sie abgelehnt wurde.
///
/// Die Sperre verdeckt die Oberfläche; die Gerätemarke bleibt im
/// Schlüsselbund wie zuvor (siehe `Ablage.hatPIN`). Nach fünf falschen PINs
/// meldet die App ab, sonst liesse sich eine sechsstellige PIN in Ruhe
/// durchprobieren.
struct SperrAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var pin = ""
@State private var meldung: String?
@State private var biometrieLaeuft = false
@FocusState private var imFeld: Bool
/// Face ID, Touch ID oder gar nichts – entscheidet Beschriftung und Symbol.
private var biometrie: LABiometryType {
let pruefer = LAContext()
guard pruefer.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
error: nil) else { return .none }
return pruefer.biometryType
}
var body: some View {
VStack(spacing: 28) {
Spacer()
Image(systemName: "lock.fill")
.font(.system(size: 44, weight: .medium))
.foregroundStyle(Color.marke)
Text(String(localized: "sperre.titel"))
.font(.title2.weight(.semibold))
VStack(spacing: 12) {
SecureField(String(localized: "sperre.pin"), text: $pin)
.textContentType(.password)
.keyboardType(.numberPad)
.multilineTextAlignment(.center)
.monospaced()
.font(.title3)
.focused($imFeld)
.padding(.vertical, 12)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12,
style: .continuous))
.onChange(of: pin) { _, neu in
pin = String(neu.filter(\.isNumber).prefix(Ziffern.anzahl))
if pin.count == Ziffern.anzahl { pruefe() }
}
if let meldung {
Text(meldung)
.font(.footnote)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: 240)
if biometrie != .none {
Button {
Task { await versucheBiometrie() }
} label: {
Label(biometrieName, systemImage: biometrieSymbol)
}
.disabled(biometrieLaeuft)
}
Spacer()
Button(String(localized: "sperre.abmelden"), role: .destructive) {
Task { await zustand.meldeAb() }
}
.font(.footnote)
.padding(.bottom, 24)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
.task { await versucheBiometrie() }
}
private var biometrieName: String {
biometrie == .touchID
? String(localized: "sperre.touchid")
: String(localized: "sperre.faceid")
}
private var biometrieSymbol: String {
biometrie == .touchID ? "touchid" : "faceid"
}
/// Fragt Face ID. Jede Ablehnung – abgebrochen, nicht erkannt, nicht
/// eingerichtet – führt schlicht zum PIN-Feld; das ist der Rückfallweg.
private func versucheBiometrie() async {
guard biometrie != .none, !biometrieLaeuft else {
imFeld = true
return
}
biometrieLaeuft = true
defer { biometrieLaeuft = false }
let pruefer = LAContext()
// Der Systemdialog bietet sonst „Passcode eingeben" an – das wäre der
// Gerätecode, nicht unsere PIN, und damit verwirrend.
pruefer.localizedFallbackTitle = ""
let geglueckt = (try? await pruefer.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: String(localized: "sperre.grund"))) ?? false
if geglueckt {
zustand.entsperrt()
} else {
imFeld = true
}
}
private func pruefe() {
guard Ablage.stimmtPIN(pin) else {
pin = ""
Task {
let weiter = await zustand.falschePIN()
if weiter {
meldung = String(format: String(localized: "sperre.falsch"),
zustand.verbleibendeVersuche)
}
}
return
}
meldung = nil
zustand.entsperrt()
}
}
enum Ziffern {
/// Sechs Stellen, wie der Gerätecode und wie der zweite Faktor.
static let anzahl = 6
}
/// PIN festlegen oder ändern. Beim ersten Mal führt kein Weg daran vorbei –
/// darum gibt es dann kein „Abbrechen".
struct PINEinrichtenAnsicht: View {
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
/// Beim ersten Einrichten Pflicht, beim Ändern aus den Einstellungen nicht.
var pflicht = true
@State private var neue = ""
@State private var wiederholung = ""
@State private var meldung: String?
var body: some View {
NavigationStack {
Form {
Section {
SecureField(String(localized: "pin.neu"), text: $neue)
.keyboardType(.numberPad)
.monospaced()
.onChange(of: neue) { _, wert in
neue = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl))
}
SecureField(String(localized: "pin.wiederholen"), text: $wiederholung)
.keyboardType(.numberPad)
.monospaced()
.onChange(of: wiederholung) { _, wert in
wiederholung = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl))
}
} footer: {
// Keine Überschrift: Die steht schon in der Leiste oben.
Text(String(localized: "pin.erklaerung"))
}
if let meldung {
Section {
Label(meldung, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
Section {
Button(String(localized: "pin.sichern"), action: lege)
.disabled(neue.count < Ziffern.anzahl
|| wiederholung.count < Ziffern.anzahl)
}
}
.navigationTitle(String(localized: "pin.einrichtenTitel"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if !pflicht {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
}
}
}
}
private func lege() {
guard neue.count == Ziffern.anzahl else {
meldung = String(localized: "pin.zuKurz"); return
}
guard neue == wiederholung else {
meldung = String(localized: "pin.ungleich")
wiederholung = ""
return
}
zustand.setzePIN(neue)
if !pflicht { schliesse() }
}
}
+123
View File
@@ -0,0 +1,123 @@
import SwiftUI
/// Der zweite Faktor. Entweder sechs Ziffern aus der Authenticator-App oder
/// ein Wiederherstellungscode der Form `ABCDE-FGHIJ`.
///
/// Fünf Fehlversuche, dann ist die Zwischensitzung verbraucht und es geht
/// zurück zur Anmeldung – so hält es das Backend
/// (`backend/app/services/sessions.py`, `MAX_2FA_ATTEMPTS`).
struct ZweiFaktorAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var code = ""
@State private var mitWiederherstellung = false
@FocusState private var imFeld: Bool
var body: some View {
NavigationStack {
Form {
Section {
TextField(platzhalter, text: $code)
.textContentType(mitWiederherstellung ? .none : .oneTimeCode)
.keyboardType(mitWiederherstellung ? .asciiCapable : .numberPad)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.monospaced()
.focused($imFeld)
.submitLabel(.go)
.onSubmit { pruefe() }
.onChange(of: code) { _, neu in
// Sechs Ziffern aus der App: direkt absenden, das
// spart den Griff zur Tastatur.
if !mitWiederherstellung, neu.count == 6, neu.allSatisfy(\.isNumber) {
pruefe()
}
}
} header: {
Text(String(localized: "zweifaktor.ueberschrift"))
} footer: {
Text(mitWiederherstellung
? String(localized: "zweifaktor.erklaerungWiederherstellung")
: String(localized: "zweifaktor.erklaerungCode"))
}
if let meldung = zustand.meldung {
Section {
Label(meldung, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
Section {
Button(action: pruefe) {
if zustand.beschaeftigt {
ProgressView()
} else {
Text(String(localized: "zweifaktor.bestaetigen"))
}
}
.disabled(code.isEmpty || zustand.beschaeftigt)
Button(mitWiederherstellung
? String(localized: "zweifaktor.dochCode")
: String(localized: "zweifaktor.dochWiederherstellung")) {
mitWiederherstellung.toggle()
code = ""
imFeld = true
}
}
Section {
Button(String(localized: "zweifaktor.abbrechen"), role: .destructive) {
Task { await zustand.meldeAb() }
}
}
}
.navigationTitle(String(localized: "zweifaktor.titel"))
.onAppear { imFeld = true }
}
}
private var platzhalter: String {
mitWiederherstellung
? String(localized: "zweifaktor.platzhalterWiederherstellung")
: String(localized: "zweifaktor.platzhalterCode")
}
private func pruefe() {
let eingabe = code.trimmingCharacters(in: .whitespaces)
guard !eingabe.isEmpty else { return }
Task {
await zustand.pruefeZweitenFaktor(
code: eingabe, wiederherstellung: mitWiederherstellung)
code = ""
}
}
}
/// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen.
/// Einrichten geht in Stufe 1 noch nicht in der App – also sagen wir das
/// klar, statt den Nutzer gegen eine Wand laufen zu lassen.
struct ZweitenFaktorEinrichtenHinweis: View {
@Environment(AppZustand.self) private var zustand
var body: some View {
NavigationStack {
ContentUnavailableView {
Label(String(localized: "zweifaktorEinrichten.titel"),
systemImage: "lock.shield")
} description: {
Text(String(localized: "zweifaktorEinrichten.erklaerung"))
} actions: {
if let adresse = zustand.serverAdresse {
Link(String(localized: "zweifaktorEinrichten.imBrowser"),
destination: adresse)
.buttonStyle(.borderedProminent)
}
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
Task { await zustand.meldeAb() }
}
}
.navigationTitle(String(localized: "zweifaktorEinrichten.titel"))
}
}
}
+344
View File
@@ -0,0 +1,344 @@
import Foundation
import Observation
#if canImport(UIKit)
import UIKit
#endif
/// Wo die App gerade steht – und damit, welcher Bildschirm zu sehen ist.
@MainActor
@Observable
final class AppZustand {
enum Lage: Equatable {
/// Beim Start: Gibt es eine Adresse, gibt es eine Marke, gilt sie noch?
case startet
/// Keine Server-Adresse hinterlegt.
case brauchtServer
/// Adresse steht, aber keine gültige Marke.
case brauchtAnmeldung
/// Passwort stimmt, der Code fehlt noch.
case brauchtZweitenFaktor
/// Das Konto muss erst sein Passwort wechseln.
case brauchtPasswortwechsel
/// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen.
/// Einrichten geht in Stufe 1 noch nicht in der App.
case brauchtZweitenFaktorEinrichtung
/// Alles gut.
case bereit(Benutzer)
}
private(set) var lage: Lage = .startet
private(set) var einstellungen = Einstellungen()
private(set) var client: CertainClient?
/// Kategorien, Schlagwörter und Konten – einmal geladen, von allen
/// Listen benutzt. Jede Zeile will zu einer ID den Namen, das Symbol und
/// die Farbe; das je Ansicht neu zu holen wäre genau das N+1, das das
/// Backend sich selbst verbietet.
private(set) var nachschlag = Nachschlag.leer
/// Die letzte Meldung für den Anmeldebildschirm. Keine Fehleransicht,
/// sondern eine Zeile unter dem Formular.
var meldung: String?
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
private(set) var beschaeftigt = false
// MARK: - Bildschirmsperre
/// Liegt der Sperrbildschirm über der App?
private(set) var gesperrt = false
/// Ob eine PIN eingerichtet ist. Als Eigenschaft und nicht direkt aus der
/// Ablage gelesen, damit die Ansicht nach dem Einrichten neu zeichnet.
private(set) var hatPIN = Ablage.hatPIN
private var seitWannWeg: Date?
private var fehlversuche = 0
/// So lange darf man weg sein, ohne erneut zu entsperren.
private static let gnadenfrist: TimeInterval = 60
/// Danach fliegt die Marke raus und es geht zur Anmeldung. Ohne Grenze
/// wäre eine sechsstellige PIN in Ruhe durchprobierbar.
private static let maxFehlversuche = 5
var serverAdresse: URL? { Ablage.serverAdresse }
// MARK: - Start
func starte() async {
guard let adresse = Ablage.serverAdresse else {
lage = .brauchtServer
return
}
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
client = neuer
// Kaltstart: wer eine PIN hat, sieht zuerst den Sperrbildschirm.
hatPIN = Ablage.hatPIN
gesperrt = hatPIN
guard Ablage.marke != nil else {
lage = .brauchtAnmeldung
return
}
await ladeEigenenZustand()
}
/// Fragt den Server, wer wir sind. Das ist zugleich die Prüfung, ob die
/// Marke noch gilt.
private func ladeEigenenZustand() async {
guard let client else { return }
do {
let ich = try await client.ichSelbst()
// Muss gemerkt werden, sonst greift der Offline-Zweig unten nie:
// Niemand sonst schreibt unter diesem Schlüssel.
Zwischenspeicher.lege(ich, unter: "auth/me")
einstellungen = (try? await client.einstellungen()) ?? Einstellungen()
lage = .bereit(ich)
await ladeNachschlag()
} catch APIFehler.nichtAngemeldet {
// Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten.
Ablage.marke = nil
await client.setzeMarke(nil)
lage = .brauchtAnmeldung
} catch APIFehler.pflichtOffen(.passwortWechseln) {
lage = .brauchtPasswortwechsel
} catch APIFehler.pflichtOffen(.zweiterFaktor) {
lage = .brauchtZweitenFaktorEinrichtung
} catch {
// Offline beim Start: Wir lassen die App herein und zeigen in den
// Ansichten den letzten Stand. Die Marke ist ja da.
if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") {
lage = .bereit(gemerkt.wert)
await ladeNachschlag()
} else {
meldung = error.localizedDescription
lage = .brauchtAnmeldung
}
}
}
// MARK: - Server festlegen
/// Nimmt, was der Nutzer tippt, und macht daraus eine brauchbare Adresse.
/// `budget.example.com` wird zu `https://budget.example.com`.
///
/// `nonisolated`, weil es reine Textarbeit ist – so lässt es sich ohne
/// Hauptthread testen.
nonisolated static func deuteAdresse(_ eingabe: String) -> URL? {
var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
if !text.contains("://") { text = "https://" + text }
// Ein angehängter Pfad hilft nicht – die API hängt immer unter /api.
guard var teile = URLComponents(string: text), teile.host?.isEmpty == false else {
return nil
}
teile.path = ""
teile.query = nil
teile.fragment = nil
return teile.url
}
func pruefeUndMerkeServer(_ eingabe: String) async -> Bool {
guard let adresse = Self.deuteAdresse(eingabe) else {
meldung = String(localized: "server.keineAdresse")
return false
}
beschaeftigt = true
defer { beschaeftigt = false }
meldung = nil
let versuch = CertainClient(basis: adresse)
do {
_ = try await versuch.serverkennung()
Ablage.serverAdresse = adresse
client = versuch
lage = .brauchtAnmeldung
return true
} catch {
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
return false
}
}
// MARK: - Anmelden
func melde(benutzer: String, passwort: String) async {
guard let client else { return }
beschaeftigt = true
defer { beschaeftigt = false }
meldung = nil
do {
let antwort = try await client.melde(benutzer: benutzer, passwort: passwort)
Ablage.benutzername = benutzer
if antwort.brauchtZweitenFaktor {
lage = .brauchtZweitenFaktor
return
}
await nachDerAnmeldung(antwort.user)
} catch {
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
}
}
func pruefeZweitenFaktor(code: String, wiederherstellung: Bool) async {
guard let client else { return }
beschaeftigt = true
defer { beschaeftigt = false }
meldung = nil
do {
let antwort = try await client.pruefeZweitenFaktor(
code: code, wiederherstellung: wiederherstellung)
await nachDerAnmeldung(antwort.user)
} catch APIFehler.nichtAngemeldet {
// Zu viele Fehlversuche: Das Zwischen-Cookie ist weg.
meldung = String(localized: "zweifaktor.verbraucht")
lage = .brauchtAnmeldung
} catch {
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
}
}
/// Nach geglückter Anmeldung: Marke holen und das Cookie wegwerfen.
private func nachDerAnmeldung(_ benutzer: Benutzer?) async {
guard let client else { return }
// Offene Pflichten zuerst – mit ihnen lässt sich keine Marke anlegen,
// das Backend sperrt den Endpunkt (`enforce_account_state`).
if let benutzer, benutzer.mustChangePassword {
lage = .brauchtPasswortwechsel
return
}
if let benutzer, benutzer.mfaSetupRequired {
lage = .brauchtZweitenFaktorEinrichtung
return
}
do {
let marke = try await client.legeAppMarkeAn(name: Self.geraetename)
guard let klartext = marke.token else {
meldung = String(localized: "anmeldung.markeOhneSchluessel")
return
}
Ablage.marke = klartext
await client.setzeMarke(klartext)
// Ab hier trägt die Marke. Das Cookie hat seinen Zweck erfüllt.
await client.vergissCookies()
await ladeEigenenZustand()
} catch {
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
}
}
func wechslePasswort(alt: String, neu: String) async {
guard let client else { return }
beschaeftigt = true
defer { beschaeftigt = false }
meldung = nil
do {
let ich = try await client.wechslePasswort(alt: alt, neu: neu)
await nachDerAnmeldung(ich)
} catch {
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
}
}
// MARK: - Abmelden
/// Meldet ab und räumt auf. Die Marke wird dabei am Server widerrufen –
/// sonst bliebe sie dort stehen, bis jemand sie von Hand entfernt.
func meldeAb(adresseBehalten: Bool = true) async {
if let client, Ablage.marke != nil {
// Welche der Marken unsere ist, lässt sich nicht am Klartext
// ablesen – am Server liegt nur der Hash. Also über den Namen,
// unter dem wir sie angelegt haben.
let meine = (try? await client.marken())?.first {
$0.scope == "app" && $0.name == Self.geraetename
}
if let meine { try? await client.widerrufeMarke(id: meine.id) }
}
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
Ablage.raeumeAuf()
Ablage.serverAdresse = adresse
hatPIN = false
gesperrt = false
fehlversuche = 0
if let adresse {
client = CertainClient(basis: adresse)
lage = .brauchtAnmeldung
} else {
client = nil
lage = .brauchtServer
}
}
// MARK: - Sperren und entsperren
/// iOS fotografiert die App beim Wegschalten für den App-Umschalter.
/// Darum wird sofort gesperrt und nicht erst beim Zurückkommen – sonst
/// stünden die Kontostände im Umschalter.
func gehtWeg() {
guard hatPIN, !gesperrt else { return }
seitWannWeg = Date()
gesperrt = true
}
/// Kurz weggewischt – etwa für eine eintreffende Nachricht – geht ohne
/// Nachfrage wieder auf. Erst nach der Gnadenfrist wird gefragt.
func kommtZurueck() {
guard let weg = seitWannWeg else { return }
seitWannWeg = nil
if Date().timeIntervalSince(weg) <= Self.gnadenfrist { gesperrt = false }
}
func entsperrt() {
gesperrt = false
fehlversuche = 0
}
/// Meldet einen Fehlversuch. Gibt zurück, ob noch Versuche übrig sind;
/// beim letzten meldet die App ab.
func falschePIN() async -> Bool {
fehlversuche += 1
guard fehlversuche >= Self.maxFehlversuche else { return true }
await meldeAb()
return false
}
var verbleibendeVersuche: Int { max(0, Self.maxFehlversuche - fehlversuche) }
func setzePIN(_ pin: String) {
Ablage.setzePIN(pin)
hatPIN = true
gesperrt = false
fehlversuche = 0
}
func aktualisiereEinstellungen() async {
guard let client else { return }
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
}
/// Holt das Nachschlagewerk. Geht das schief, bleibt der letzte Stand –
/// eine Buchungsliste ohne Kategorienamen wäre schlechter als eine mit
/// alten.
func ladeNachschlag() async {
guard let client else { return }
if let frisch = try? await client.nachschlag() {
Zwischenspeicher.lege(frisch, unter: "nachschlag")
nachschlag = frisch
} else if nachschlag.gruppen.isEmpty,
let gemerkt = Zwischenspeicher.hole(Nachschlag.self, unter: "nachschlag") {
nachschlag = gemerkt.wert
}
}
// MARK: - Kleinkram
/// Der Name, unter dem die Marke am Rechner auftaucht. Soll erkennbar
/// machen, welches Gerät man widerruft.
static var geraetename: String {
#if canImport(UIKit)
let roh = UIDevice.current.name
return roh.isEmpty ? "iPhone" : roh
#else
return "iOS-Gerät"
#endif
}
}
+38
View File
@@ -0,0 +1,38 @@
import SwiftUI
@main
struct CertainApp: App {
@State private var zustand = AppZustand()
@Environment(\.scenePhase) private var phase
var body: some Scene {
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
// und Weboberfläche gleich aussehen. „system" heisst: nichts vorgeben.
let schema: ColorScheme?
switch zustand.einstellungen.erscheinungsbild {
case "light": schema = .light
case "dark": schema = .dark
default: schema = nil
}
return WindowGroup {
RootAnsicht()
.environment(zustand)
.task { await zustand.starte() }
.preferredColorScheme(schema)
// Einmal hier, damit jeder Knopf, Schalter und Link
// die Markenfarbe traegt statt des System-Blaus.
.tint(.marke)
.onChange(of: phase) { _, neue in
// `inactive` kommt, bevor iOS das Bild für den
// App-Umschalter macht. Dort soll der Sperrbildschirm
// stehen, nicht der Kontostand.
switch neue {
case .inactive, .background: zustand.gehtWeg()
case .active: zustand.kommtZurueck()
@unknown default: break
}
}
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import SwiftUI
/// Welcher Bildschirm zu sehen ist, entscheidet allein `AppZustand.lage`.
/// Jede Ansicht darunter darf davon ausgehen, dass es einen angemeldeten
/// Client gibt.
struct RootAnsicht: View {
@Environment(AppZustand.self) private var zustand
var body: some View {
switch zustand.lage {
case .startet:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .brauchtServer:
ServerAnsicht()
case .brauchtAnmeldung:
AnmeldeAnsicht()
case .brauchtZweitenFaktor:
ZweiFaktorAnsicht()
case .brauchtPasswortwechsel:
PasswortWechselAnsicht()
case .brauchtZweitenFaktorEinrichtung:
ZweitenFaktorEinrichtenHinweis()
case .bereit:
// Reihenfolge mit Absicht: Ohne PIN kommt niemand an die Zahlen,
// und der Sperrbildschirm liegt vor allem anderen.
if !zustand.hatPIN {
PINEinrichtenAnsicht()
} else if zustand.gesperrt {
SperrAnsicht()
} else {
Hauptnavigation()
}
}
}
}
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
/// erledigt `.sidebarAdaptable` von selbst.
///
/// Reihenfolge nach Häufigkeit, nicht nach Umfang: Das Dashboard beantwortet
/// die Frage, wegen der man die App öffnet. Konten und Einstellungen stehen
/// hinten, weil man sie selten braucht.
struct Hauptnavigation: View {
var body: some View {
TabView {
Tab(String(localized: "reiter.dashboard"), systemImage: "square.grid.2x2") {
NavigationStack { DashboardAnsicht() }
}
Tab(String(localized: "reiter.budget"), systemImage: "chart.pie") {
NavigationStack { BudgetAnsicht() }
}
Tab(String(localized: "reiter.buchungen"), systemImage: "list.bullet.rectangle") {
NavigationStack { BuchungsListe() }
}
Tab(String(localized: "reiter.arbeitsliste"), systemImage: "tray") {
NavigationStack { ArbeitslisteAnsicht() }
}
Tab(String(localized: "reiter.abos"), systemImage: "arrow.triangle.2.circlepath") {
NavigationStack { AboListe() }
}
Tab(String(localized: "reiter.konten"), systemImage: "wallet.bifold") {
NavigationStack { KontenListe() }
}
Tab(String(localized: "reiter.einstellungen"), systemImage: "gearshape") {
NavigationStack { EinstellungenAnsicht() }
}
}
.tabViewStyle(.sidebarAdaptable)
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+42
View File
@@ -0,0 +1,42 @@
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.35897,0.51845,0.35368,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "display-p3:0.16927,0.24447,0.16678,1.00000"
}
}
],
"groups" : [
{
"layers" : [
{
"fill" : {
"solid" : "display-p3:0.82502,0.96783,0.89028,1.00000"
},
"image-name" : "Marke.svg",
"name" : "Marke"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.3
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
+478
View File
@@ -0,0 +1,478 @@
import SwiftUI
/// Alles, worüber jemand entscheiden muss, in einem Abruf.
///
/// Vier Quellen, die nichts voneinander wissen – aber einen gemeinsamen
/// Stand ergeben sollen. Vier getrennte Zwischenspeicher könnten offline aus
/// vier verschiedenen Momenten stammen.
struct Arbeitsstand: Codable, Sendable, Hashable {
let eingang: [Eingangseintrag]
let vorschlaege: Uebertragsvorschlaege
let dubletten: Dublettengruppen
/// Gebuchtes ohne Kategorie – der stillste der vier Posten. Ohne
/// Kategorie zählt eine Ausgabe in keiner Budget-Zeile mit.
let ohneKategorie: [Buchung]
var istLeer: Bool {
offenerEingang.isEmpty && vorschlaege.vorschlaege.isEmpty
&& dubletten.gruppen.isEmpty && ohneKategorie.isEmpty
}
/// Was im Eingang noch eine Entscheidung braucht. Gebuchtes ist erledigt
/// und steht nur im Weg.
var offenerEingang: [Eingangseintrag] {
eingang.filter { $0.status == .pending || $0.status == .unclear }
}
var anzahlOffen: Int {
offenerEingang.count + vorschlaege.vorschlaege.count
+ dubletten.gruppen.count + ohneKategorie.count
}
}
extension CertainClient {
func arbeitsstand() async throws -> Arbeitsstand {
async let eingang = self.eingang()
async let vorschlaege = uebertragsvorschlaege()
async let dubletten = self.dubletten()
// Das Backend lässt Überträge hier schon aussen vor und überspringt
// verknüpfte Buchungen – siehe den uncategorized-Zweig im Router.
async let ohneKategorie = buchungen(ohneKategorie: true, grenze: 100)
return try await Arbeitsstand(eingang: eingang, vorschlaege: vorschlaege,
dubletten: dubletten, ohneKategorie: ohneKategorie)
}
}
/// Die Arbeitsliste: eine Zeile, eine Entscheidung.
///
/// Fachlich der Teil, der am besten aufs Telefon gehört – man arbeitet ihn
/// zwischendurch ab, nicht am Schreibtisch.
struct ArbeitslisteAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<Arbeitsstand>?
@State private var vorgang = Speichervorgang()
@State private var kategorieFuer: Eingangseintrag?
@State private var kategorieFuerBuchung: Buchung?
@State private var uebertragFuer: Eingangseintrag?
@State private var rueckfrage: Rueckfrage?
/// Was das Backend mit 409 beantwortet hat, samt dem Aufruf, der sich
/// mit `trotzdem` wiederholen lässt.
struct Rueckfrage: Identifiable {
let id = UUID()
let text: String
let nochmal: () async -> Void
}
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { stand in
liste(stand)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "reiter.arbeitsliste"))
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<Arbeitsstand>(schluessel: "arbeitsliste") {
try await client.arbeitsstand()
}
lader = neuer
await neuer.lade()
}
.sheet(item: $kategorieFuer) { eintrag in
KategorieWaehlen(titel: eintrag.payee ?? eintrag.quelle,
nachschlag: zustand.nachschlag) { kategorie in
await tu { try await $0.setzeEingangsKategorie(eintrag.id, kategorie: kategorie) }
}
}
.sheet(item: $kategorieFuerBuchung) { buchung in
KategorieWaehlen(titel: buchung.ueberschrift,
nachschlag: zustand.nachschlag,
art: buchung.type == .income ? .income : .expense) { kategorie in
await tu { try await $0.aendereBuchung(buchung.id,
BuchungAendern(categoryId: kategorie)) }
}
}
.sheet(item: $uebertragFuer) { eintrag in
ZielkontoWaehlen(nachschlag: zustand.nachschlag, ausser: eintrag.accountId) { ziel in
await bucheAlsUebertrag(eintrag, ziel: ziel, trotzdem: false)
}
}
.alert(item: $rueckfrage) { frage in
Alert(title: Text(String(localized: "arbeit.rueckfrage")),
message: Text(frage.text),
primaryButton: .default(Text(String(localized: "arbeit.trotzdem"))) {
Task { await frage.nochmal() }
},
secondaryButton: .cancel(Text(String(localized: "allgemein.abbrechen"))))
}
.alert(String(localized: "fehler.titel"),
isPresented: .constant(vorgang.fehler != nil)) {
Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil }
} message: {
Text(vorgang.fehler ?? "")
}
}
// MARK: - Liste
@ViewBuilder
private func liste(_ stand: Arbeitsstand) -> some View {
if stand.istLeer {
LeerAnsicht(titel: String(localized: "arbeit.leerTitel"),
erklaerung: String(localized: "arbeit.leerErklaerung"),
symbol: "checkmark.circle")
} else {
List {
if !stand.offenerEingang.isEmpty {
Section {
ForEach(stand.offenerEingang) { eintrag in
EingangsZeile(eintrag: eintrag, nachschlag: zustand.nachschlag,
buchen: { Task { await buche(eintrag) } },
uebertrag: { uebertragFuer = eintrag },
verwerfen: { Task { await verwirf(eintrag) } })
}
} header: {
Kopf(titel: String(localized: "arbeit.eingang"),
anzahl: stand.offenerEingang.count)
} footer: {
if stand.offenerEingang.contains(where: \.laesstSichBuchen) {
Button(String(localized: "arbeit.alleBuchen")) {
Task { await tu { _ = try await $0.bucheGanzenEingang() } }
}
.disabled(vorgang.laeuft)
}
}
}
if !stand.vorschlaege.vorschlaege.isEmpty {
Section {
ForEach(stand.vorschlaege.vorschlaege) { paar in
VorschlagsZeile(paar: paar,
verbinden: { Task { await verbinde(paar) } },
ablehnen: { Task { await lehneAb(paar) } })
}
} header: {
Kopf(titel: String(localized: "arbeit.uebertraege"),
anzahl: stand.vorschlaege.vorschlaege.count)
}
}
if !stand.ohneKategorie.isEmpty {
Section {
ForEach(stand.ohneKategorie) { buchung in
Button {
kategorieFuerBuchung = buchung
} label: {
OhneKategorieZeile(buchung: buchung,
nachschlag: zustand.nachschlag)
}
.buttonStyle(.plain)
}
} header: {
Kopf(titel: String(localized: "arbeit.ohneKategorie"),
anzahl: stand.ohneKategorie.count)
}
}
if !stand.dubletten.gruppen.isEmpty {
Section {
ForEach(stand.dubletten.gruppen) { gruppe in
DublettenZeile(gruppe: gruppe, nachschlag: zustand.nachschlag) {
Task { await behalte(gruppe) }
}
}
} header: {
Kopf(titel: String(localized: "arbeit.dubletten"),
anzahl: stand.dubletten.gruppen.count)
} footer: {
Text(String(localized: "arbeit.dublettenErklaerung"))
}
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
.disabled(vorgang.laeuft)
}
}
// MARK: - Entscheidungen
/// Jede Entscheidung läuft gleich: schreiben, dann neu laden. Die Liste
/// muss danach stimmen – ein erledigter Posten, der stehen bleibt, führt
/// zum zweiten Klick auf dieselbe Sache.
private func tu(_ arbeit: @escaping (CertainClient) async throws -> Void) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await arbeit(client) }) {
await lader?.aktualisiere()
}
}
private func buche(_ eintrag: Eingangseintrag) async {
await tu { _ = try await $0.bucheEingang(eintrag.id) }
}
private func verwirf(_ eintrag: Eingangseintrag) async {
await tu { try await $0.verwerfeEingang(eintrag.id) }
}
/// Der einzige 409-Fall der App: Das Backend hat Bedenken und lässt sich
/// mit `trotzdem` überstimmen. Den Hinweis zu verschlucken wäre falsch,
/// aber ihn als blossen Fehler zu zeigen auch – er ist eine Frage.
private func bucheAlsUebertrag(_ eintrag: Eingangseintrag, ziel: Int,
trotzdem: Bool) async {
guard let client = zustand.client else { return }
do {
_ = try await client.bucheEingangAlsUebertrag(eintrag.id, aufKonto: ziel,
trotzdem: trotzdem)
await lader?.aktualisiere()
} catch APIFehler.konflikt(let text) {
rueckfrage = Rueckfrage(text: text) {
await bucheAlsUebertrag(eintrag, ziel: ziel, trotzdem: true)
}
} catch {
vorgang.fehler = (error as? APIFehler)?.errorDescription
?? error.localizedDescription
}
}
private func verbinde(_ paar: Uebertragsvorschlaege.Vorschlag) async {
await tu { try await $0.verbindeUebertrag(paar.a.id, paar.b.id) }
}
private func lehneAb(_ paar: Uebertragsvorschlaege.Vorschlag) async {
await tu { try await $0.lehneUebertragAb(paar.a.id, paar.b.id) }
}
private func behalte(_ gruppe: Dublettengruppen.Gruppe) async {
await tu { try await $0.behalteDubletten(gruppe.zeilen.map(\.id)) }
}
}
// MARK: - Bausteine
private struct Kopf: View {
let titel: String
let anzahl: Int
var body: some View {
HStack {
Text(titel)
Spacer()
Text("\(anzahl)")
.monospacedDigit()
.padding(.horizontal, 7)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
.textCase(nil)
.font(.footnote)
}
}
private struct EingangsZeile: View {
let eintrag: Eingangseintrag
let nachschlag: Nachschlag
let buchen: () -> Void
let uebertrag: () -> Void
let verwerfen: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(eintrag.payee ?? String(localized: "buchung.ohneBezeichnung"))
.font(.callout)
.lineLimit(1)
Text(unterzeile)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text(eintrag.betrag.formatiert())
.font(.callout.monospacedDigit())
.foregroundStyle(eintrag.amountCents < 0 ? Color.primary : Color.green)
}
if eintrag.status == .unclear {
// Die Erkennung war unsicher. Das gehört vor die Knöpfe, nicht
// dahinter – sonst bucht man es weg, ohne es gelesen zu haben.
Label(String(localized: "arbeit.unklarHinweis"),
systemImage: "questionmark.circle")
.font(.caption2)
.foregroundStyle(.orange)
}
HStack(spacing: 8) {
Button(String(localized: "arbeit.buchen"), action: buchen)
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!eintrag.laesstSichBuchen)
Button(String(localized: "arbeit.alsUebertrag"), action: uebertrag)
.buttonStyle(.bordered)
.controlSize(.small)
Spacer()
Button(String(localized: "arbeit.verwerfen"), role: .destructive,
action: verwerfen)
.buttonStyle(.bordered)
.controlSize(.small)
}
.buttonStyle(.bordered)
}
.padding(.vertical, 4)
}
private var unterzeile: String {
var teile = [eintrag.quelle, eintrag.date.formatiert()]
if let konto = nachschlag.konto(eintrag.accountId) {
teile.append(konto.name)
} else {
// Ohne Konto lässt es sich nicht buchen. Das soll dastehen,
// statt dass der Knopf unerklärt grau bleibt.
teile.append(String(localized: "arbeit.ohneKonto"))
}
return teile.joined(separator: " · ")
}
}
private struct VorschlagsZeile: View {
let paar: Uebertragsvorschlaege.Vorschlag
let verbinden: () -> Void
let ablehnen: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
seite(paar.a)
HStack(spacing: 6) {
Image(systemName: "arrow.down")
.font(.caption2)
.foregroundStyle(.secondary)
Text(paar.a.date.formatiert())
.font(.caption2)
.foregroundStyle(.secondary)
}
seite(paar.b)
if paar.hatKategorie {
// Eine zugeordnete Buchung fällt durchs Verbinden aus dem
// Budget. Das soll niemanden überraschen.
Label(String(localized: "arbeit.hatKategorie"),
systemImage: "exclamationmark.circle")
.font(.caption2)
.foregroundStyle(.orange)
}
HStack(spacing: 8) {
Button(String(localized: "arbeit.verbinden"), action: verbinden)
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button(String(localized: "arbeit.keinUebertrag"), action: ablehnen)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.vertical, 4)
}
private func seite(_ seite: Uebertragsvorschlaege.Seite) -> some View {
HStack(spacing: 8) {
Image(systemName: seite.type.symbol)
.font(.caption)
.foregroundStyle(seite.type == .income ? .green : .secondary)
VStack(alignment: .leading, spacing: 1) {
Text(seite.accountName)
.font(.callout)
.lineLimit(1)
Text(seite.bezeichnung)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text(seite.betrag.formatiert())
.font(.callout.monospacedDigit())
}
}
}
private struct OhneKategorieZeile: View {
let buchung: Buchung
let nachschlag: Nachschlag
var body: some View {
HStack(spacing: 10) {
Symbolplakette(symbol: "more-horizontal", farbe: "#6b7480", groesse: 32)
VStack(alignment: .leading, spacing: 1) {
Text(buchung.ueberschrift)
.font(.callout)
.lineLimit(1)
Text("\(buchung.date.formatiert()) · \(nachschlag.kontoname(buchung.accountId))")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Text(buchung.betrag.formatiert(vorzeichen: .ohne))
.font(.callout.monospacedDigit())
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 2)
}
}
private struct DublettenZeile: View {
let gruppe: Dublettengruppen.Gruppe
let nachschlag: Nachschlag
let behalten: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(Geldbetrag(cents: gruppe.amountCents,
waehrung: waehrung).formatiert())
.font(.callout.monospacedDigit())
Text("· \(gruppe.zeilen.count)×")
.font(.callout)
.foregroundStyle(.secondary)
Spacer()
Text("\(gruppe.datum.formatiert()) · \(nachschlag.kontoname(gruppe.accountId))")
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(gruppe.zeilen) { zeile in
HStack(spacing: 6) {
Text("•").foregroundStyle(.tertiary)
Text(zeile.bezeichnung)
.lineLimit(1)
if let kennung = zeile.kennung {
// Verschiedene Kennungen heissen: Die Bank hat sie
// ausdrücklich als verschiedene Zahlungen gemeldet.
Text(kennung)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Spacer()
Text(zeile.herkunft)
.foregroundStyle(.tertiary)
}
.font(.caption2)
}
Button(String(localized: "arbeit.istRichtigSo"), action: behalten)
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.vertical, 4)
}
private var waehrung: String {
nachschlag.konto(gruppe.accountId)?.currency ?? ""
}
}
+115
View File
@@ -0,0 +1,115 @@
import SwiftUI
/// Eine Kategorie zuordnen – mehr nicht.
///
/// Ein eigenes Blatt statt des ganzen Buchungsformulars: In der Arbeitsliste
/// fehlt genau diese eine Angabe, und ein Formular mit zwölf Feldern davor
/// macht aus einer Entscheidung eine Sitzung.
struct KategorieWaehlen: View {
let titel: String
let nachschlag: Nachschlag
var art: Kategorieart = .expense
/// Wird mit der gewählten ID gerufen; schliesst danach von selbst.
let waehle: (Int) async -> Void
@Environment(\.dismiss) private var schliesse
@State private var laeuft = false
var body: some View {
NavigationStack {
List {
ForEach(gruppen) { gruppe in
Section(gruppe.name) {
ForEach(gruppe.categories.filter { !$0.isArchived }) { kategorie in
Button {
Task {
laeuft = true
await waehle(kategorie.id)
schliesse()
}
} label: {
HStack(spacing: 10) {
Symbolplakette(symbol: kategorie.icon,
farbe: kategorie.color, groesse: 30)
Text(kategorie.name)
.foregroundStyle(.primary)
Spacer()
}
}
.disabled(laeuft)
}
}
}
}
.navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
}
}
}
private var gruppen: [Kategoriegruppe] {
nachschlag.gruppen
.filter { $0.kind == art }
.filter { $0.categories.contains { !$0.isArchived } }
}
}
/// Auf welches Konto der Übertrag gehen soll.
struct ZielkontoWaehlen: View {
let nachschlag: Nachschlag
/// Das Konto, von dem er kommt – es kann nicht zugleich das Ziel sein.
var ausser: Int?
let waehle: (Int) async -> Void
@Environment(\.dismiss) private var schliesse
@State private var laeuft = false
var body: some View {
NavigationStack {
List {
Section {
ForEach(auswaehlbar) { konto in
Button {
Task {
laeuft = true
await waehle(konto.id)
schliesse()
}
} label: {
HStack(spacing: 10) {
Symbolplakette(symbol: konto.icon, farbe: konto.color,
groesse: 30)
VStack(alignment: .leading, spacing: 1) {
Text(konto.name)
.foregroundStyle(.primary)
Text(konto.type.name)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
}
.disabled(laeuft)
}
} footer: {
Text(String(localized: "arbeit.zielkontoErklaerung"))
}
}
.navigationTitle(String(localized: "buchung.zielkonto"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
}
}
}
private var auswaehlbar: [Konto] {
nachschlag.konten.filter { !$0.isArchived && $0.id != ausser }
}
}
+130
View File
@@ -0,0 +1,130 @@
import SwiftUI
extension Color {
/// Die Farben aus der Datenbank sind Hex-Zeichenketten wie `#3f51b5`.
/// Alles, was nicht passt, wird zum neutralen Grau – eine kaputte Farbe
/// darf keine Ansicht leer lassen.
init(hex: String) {
var text = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if text.hasPrefix("#") { text.removeFirst() }
// Das Backend erlaubt acht Stellen (#rrggbbaa); die Durchsicht nehmen
// wir mit, der Rest wird ignoriert.
guard text.count == 6 || text.count == 8,
let zahl = UInt64(text, radix: 16) else {
self = Color(red: 0.42, green: 0.45, blue: 0.50)
return
}
let hatAlpha = text.count == 8
let r = Double((zahl >> (hatAlpha ? 24 : 16)) & 0xFF) / 255
let g = Double((zahl >> (hatAlpha ? 16 : 8)) & 0xFF) / 255
let b = Double((zahl >> (hatAlpha ? 8 : 0)) & 0xFF) / 255
let a = hatAlpha ? Double(zahl & 0xFF) / 255 : 1
self = Color(.sRGB, red: r, green: g, blue: b, opacity: a)
}
}
extension Color {
/// Die Markenfarbe: dasselbe Grün wie `--accent` in `web/src/styles.css`,
/// damit App, Weboberfläche und Appicon eine Familie sind. In Dunkel eine
/// Spur heller, sonst säuft das Grün auf dunklem Grund ab.
static let marke = Color(uiColor: UIColor { zug in
zug.userInterfaceStyle == .dark
? UIColor(red: 0.373, green: 0.741, blue: 0.569, alpha: 1) // #5fbd91
: UIColor(red: 0.247, green: 0.612, blue: 0.447, alpha: 1) // #3f9c72
})
}
/// Die Farbauswahl für Kategorien, Schlagwörter und Konten – eine Liste für
/// alle drei Formulare, damit sie nicht auseinanderlaufen. Das Markengrün
/// steht vorn und ist die Vorbelegung; der Rest bleibt bunt, sonst liessen
/// sich die Kategorien in Diagrammen nicht auseinanderhalten.
enum Farbwahl {
static let standard = "#3f9c72"
static let palette = [standard, "#3f51b5", "#b3541e", "#8e24aa",
"#c62828", "#0277bd", "#6b7480", "#5d4037"]
}
/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols.
///
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
/// SVG-Verzeichnis (`web/src/components/Icon.jsx`, knapp 600 Zeilen). Nativ
/// wäre das Unsinn: iOS bringt die Symbole mit, sie passen zur Systemschrift
/// und können fett, farbig und in jeder Grösse. Nur die Namen muss jemand
/// zuordnen – das ist diese Tabelle.
///
/// Die Liste deckt `ACCOUNT_ICONS` und `CATEGORY_ICONS` aus
/// `web/src/components/IconPicker.jsx` ab. Unbekanntes wird zum Platzhalter,
/// nie zu einer leeren Stelle.
enum Symbole {
static let platzhalter = "circle.dashed"
private static let tabelle: [String: String] = [
// Konten
"wallet": "wallet.bifold", "landmark": "building.columns",
"piggy-bank": "banknote", "vault": "lock.square.stack",
"credit-card": "creditcard", "trending-up": "chart.line.uptrend.xyaxis",
"banknote": "banknote", "coins": "centsign.circle",
// Wohnen
"home": "house", "bed": "bed.double", "zap": "bolt",
"droplet": "drop", "flame": "flame", "wifi": "wifi",
"monitor": "display", "cloud": "cloud",
// Alltag
"shopping-cart": "cart", "shopping-bag": "bag", "store": "storefront",
"utensils": "fork.knife", "coffee": "cup.and.saucer",
"wine": "wineglass", "shirt": "tshirt", "scissors": "scissors",
"sparkle": "sparkles",
// Unterwegs
"car": "car", "fuel": "fuelpump", "bus": "bus", "bike": "bicycle",
"plane": "airplane", "ticket": "ticket",
// Gesundheit
"heart-pulse": "heart.text.square", "pill": "pills",
"dumbbell": "figure.strengthtraining.traditional",
// Freizeit
"film": "film", "music": "music.note", "book-open": "book",
"newspaper": "newspaper", "graduation-cap": "graduationcap",
"paw-print": "pawprint",
// Familie
"sprout": "leaf", "cake": "birthday.cake", "gift": "gift",
"baby": "figure.and.child.holdinghands", "heart": "heart",
// Vorsorge
"umbrella": "umbrella", "life-buoy": "lifepreserver",
"shield": "shield", "scale": "scalemass",
// Sonstiges
"briefcase": "briefcase", "wrench": "wrench.adjustable",
"hammer": "hammer", "phone": "phone", "refresh-ccw": "arrow.triangle.2.circlepath",
"percent": "percent", "receipt": "receipt",
"list": "list.bullet", "more-horizontal": "ellipsis",
// In der Oberfläche benutzt, nicht in der Auswahl
"tags": "tag", "inbox": "tray", "settings": "gearshape",
"arrow-left-right": "arrow.left.arrow.right", "pie-chart": "chart.pie",
"dashboard": "square.grid.2x2", "download": "square.and.arrow.down",
"calendar": "calendar", "search": "magnifyingglass",
"edit": "pencil", "trash": "trash", "plus": "plus", "check": "checkmark",
"close": "xmark", "chevron-down": "chevron.down",
]
static func sf(_ name: String?) -> String {
guard let name, let treffer = tabelle[name] else { return platzhalter }
return treffer
}
}
/// Ein farbiges Symbolplättchen – das native Gegenstück zum `IconChip` im Web.
struct Symbolplakette: View {
let symbol: String?
let farbe: String
var groesse: CGFloat = 34
var body: some View {
let farbwert = Color(hex: farbe)
RoundedRectangle(cornerRadius: groesse * 0.28, style: .continuous)
.fill(farbwert.opacity(0.16))
.frame(width: groesse, height: groesse)
.overlay {
Image(systemName: Symbole.sf(symbol))
.font(.system(size: groesse * 0.46, weight: .medium))
.foregroundStyle(farbwert)
}
.accessibilityHidden(true)
}
}
+228
View File
@@ -0,0 +1,228 @@
import SwiftUI
import Observation
/// Ein laufendes Speichern: sperrt den Knopf und hält den Fehler fest.
///
/// Vier Formulare brauchen genau dasselbe. Zwei davon hätten es noch je für
/// sich machen können, bei vieren liefen die Kopien auseinander.
@MainActor
@Observable
final class Speichervorgang {
private(set) var laeuft = false
var fehler: String?
/// Führt die Arbeit aus und sagt, ob sie geklappt hat. Ein Fehler bleibt
/// in `fehler` stehen, damit das Formular offen bleiben und ihn zeigen
/// kann – zugeklappt wäre die Eingabe weg und die Meldung nutzlos.
func fuehreAus(_ arbeit: () async throws -> Void) async -> Bool {
guard !laeuft else { return false }
laeuft = true
fehler = nil
defer { laeuft = false }
do {
try await arbeit()
return true
} catch {
fehler = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
return false
}
}
}
/// Die Fehlerzeile unter einem Formular.
struct FehlerZeile: View {
let text: String?
var body: some View {
if let text {
Label {
Text(text)
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
}
.font(.footnote)
.foregroundStyle(.red)
}
}
}
/// Ein Betrag zum Eintippen.
///
/// Gebunden an **Cent**, nicht an Text: Die Umrechnung an genau einer Stelle
/// zu haben ist der Grund, warum `Geldbetrag` existiert. Die Zehnertastatur
/// liefert einen Punkt, die deutsche ein Komma – `ausEingabe` nimmt beides.
struct Betragsfeld: View {
let titel: String
let waehrung: String
@Binding var cents: Int
@State private var text = ""
@FocusState private var hatFokus: Bool
var body: some View {
HStack {
Text(titel)
Spacer()
TextField(Geldbetrag(cents: 0, waehrung: waehrung).alsEingabe, text: $text)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.monospacedDigit()
.focused($hatFokus)
.frame(maxWidth: 140)
Text(waehrung)
.foregroundStyle(.secondary)
}
.onAppear {
// Eine glatte Null als "0,00" vorzublenden sähe aus wie eine
// Eingabe. Leer lassen, der Platzhalter sagt genug.
if cents != 0 { text = Geldbetrag(cents: cents, waehrung: waehrung).alsEingabe }
}
.onChange(of: text) { _, neu in
cents = Geldbetrag.ausEingabe(neu, waehrung: waehrung)?.cents ?? 0
}
}
}
/// Kategorie wählen, nach Gruppen sortiert – wie im Web das Dropdown mit
/// Gruppenüberschriften.
struct KategorieFeld: View {
let titel: String
let nachschlag: Nachschlag
/// Nur Kategorien dieser Art. `nil` zeigt alle.
var art: Kategorieart?
@Binding var auswahl: Int?
var body: some View {
Picker(titel, selection: $auswahl) {
Text(String(localized: "buchung.ohneKategorie")).tag(Int?.none)
ForEach(gruppen) { gruppe in
Section(gruppe.name) {
ForEach(gruppe.categories.filter { !$0.isArchived }) { kategorie in
Text(kategorie.name).tag(Int?.some(kategorie.id))
}
}
}
}
}
private var gruppen: [Kategoriegruppe] {
nachschlag.gruppen
.filter { art == nil || $0.kind == art }
.filter { $0.categories.contains { !$0.isArchived } }
}
}
/// Konto wählen.
struct KontoFeld: View {
let titel: String
let nachschlag: Nachschlag
/// Ein Konto, das nicht zur Auswahl stehen soll – das Gegenkonto eines
/// Übertrags darf nicht dasselbe sein wie das Quellkonto.
var ausser: Int?
@Binding var auswahl: Int?
var body: some View {
Picker(titel, selection: $auswahl) {
Text(String(localized: "buchung.konto.keines")).tag(Int?.none)
ForEach(auswaehlbar) { konto in
Text(konto.name).tag(Int?.some(konto.id))
}
}
}
private var auswaehlbar: [Konto] {
nachschlag.konten.filter { !$0.isArchived && $0.id != ausser }
}
}
/// Die Schlagwörter einer Buchung an- und abwählen.
struct SchlagwortFeld: View {
let nachschlag: Nachschlag
@Binding var gewaehlt: Set<Int>
var body: some View {
if nachschlag.schlagwoerter.isEmpty {
Text(String(localized: "buchung.keineSchlagwoerter"))
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(nachschlag.schlagwoerter) { etikett in
Button {
if gewaehlt.contains(etikett.id) { gewaehlt.remove(etikett.id) }
else { gewaehlt.insert(etikett.id) }
} label: {
HStack {
Circle()
.fill(Color(hex: etikett.color))
.frame(width: 10, height: 10)
Text(etikett.name)
.foregroundStyle(.primary)
Spacer()
if gewaehlt.contains(etikett.id) {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
}
}
}
}
}
/// Symbol und Farbe wählen – für Konten und Verträge.
struct SymbolUndFarbeFeld: View {
@Binding var symbol: String
@Binding var farbe: String
/// Eine kleine Auswahl statt aller 600 Lucide-Namen: Wer mehr braucht,
/// stellt es am Rechner ein. Die Namen sind die des Backends, nicht die
/// der SF Symbols – `Symbole.sf` übersetzt beim Anzeigen.
private static let symbole = [
"wallet", "landmark", "piggy-bank", "credit-card", "banknote", "coins",
"vault", "trending-up", "home", "shopping-cart", "car", "heart",
"film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw",
]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in
Image(systemName: Symbole.sf(name))
.font(.system(size: 18))
.foregroundStyle(Color(hex: farbe))
}
auswahl(Farbwahl.palette, aktuell: farbe) { farbe = $0 } inhalt: { wert in
Circle().fill(Color(hex: wert)).frame(width: 22, height: 22)
}
}
.padding(.vertical, 4)
}
@ViewBuilder
private func auswahl<Inhalt: View>(_ werte: [String], aktuell: String,
waehle: @escaping (String) -> Void,
@ViewBuilder inhalt: @escaping (String) -> Inhalt) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(werte, id: \.self) { wert in
Button { waehle(wert) } label: {
inhalt(wert)
.frame(width: 38, height: 38)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(wert == aktuell ? Color.accentColor.opacity(0.18)
: Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(wert == aktuell ? Color.accentColor : .clear,
lineWidth: 2)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 1)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import Foundation
/// Geld, so wie das Backend es führt: ganze Cent plus Währungscode.
///
/// Nirgends Fliesskomma. Die API kennt ausschliesslich Cent-Ganzzahlen
/// (einzige Ausnahme ist der Kurzbefehl-Endpunkt), und das bleibt hier so –
/// ein `Double` hätte bei Summen über viele Buchungen Rundungsfehler.
struct Geldbetrag: Hashable, Sendable {
let cents: Int
let waehrung: String
init(cents: Int, waehrung: String) {
self.cents = cents
self.waehrung = waehrung.uppercased()
}
var dezimal: Decimal { Decimal(cents) / Decimal(100) }
var istNull: Bool { cents == 0 }
var istNegativ: Bool { cents < 0 }
/// Für die Anzeige, in der Sprache des Geräts.
///
/// `vorzeichen: .immer` schreibt auch bei positiven Beträgen ein Plus –
/// das braucht die Buchungsliste, wo Einnahme und Ausgabe nebeneinander
/// stehen und das Vorzeichen die einzige Unterscheidung ist.
func formatiert(vorzeichen: Vorzeichenstil = .nurMinus) -> String {
let wert = vorzeichen == .ohne ? abs(dezimal) : dezimal
var text = wert.formatted(.currency(code: waehrung).locale(.autoupdatingCurrent))
if vorzeichen == .immer, cents > 0 {
text = "+" + text
}
return text
}
enum Vorzeichenstil: Sendable {
/// Minus bei negativen Beträgen, sonst nichts. Der Normalfall.
case nurMinus
/// Auch bei positiven ein Plus.
case immer
/// Immer ohne – wenn die Richtung schon woanders steht.
case ohne
}
/// Ohne Nachkommastellen, für enge Stellen wie Diagramm-Achsen.
var gerundet: String {
(dezimal as NSDecimalNumber).doubleValue.formatted(
.currency(code: waehrung).precision(.fractionLength(0)))
}
static func + (links: Geldbetrag, rechts: Geldbetrag) -> Geldbetrag {
precondition(links.waehrung == rechts.waehrung,
"Betraege verschiedener Waehrungen lassen sich nicht addieren")
return Geldbetrag(cents: links.cents + rechts.cents, waehrung: links.waehrung)
}
static prefix func - (betrag: Geldbetrag) -> Geldbetrag {
Geldbetrag(cents: -betrag.cents, waehrung: betrag.waehrung)
}
}
extension Geldbetrag {
/// Was der Nutzer tippt, in Cent.
///
/// Nimmt Komma und Punkt als Dezimaltrenner – auf einer deutschen Tastatur
/// tippt man „12,50", auf der Zehnertastatur „12.50", und beides meint
/// dasselbe. Tausendertrenner und Währungszeichen fliegen raus.
static func ausEingabe(_ eingabe: String, waehrung: String) -> Geldbetrag? {
var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
let negativ = text.hasPrefix("-")
text = text.filter { $0.isNumber || $0 == "," || $0 == "." }
// Ohne eine einzige Ziffer ist das keine Zahl, sondern ein Vertipper.
// Sonst käme aus "abc" ein Betrag von 0,00 heraus.
guard text.contains(where: \.isNumber) else { return nil }
// Steht beides drin, ist das hintere der Dezimaltrenner und das
// vordere der Tausendertrenner ("1.234,50" wie "1,234.50").
let letztesKomma = text.lastIndex(of: ",")
let letzterPunkt = text.lastIndex(of: ".")
let trenner: Character?
switch (letztesKomma, letzterPunkt) {
case (nil, nil): trenner = nil
case (_?, nil): trenner = ","
case (nil, _?): trenner = "."
case (let komma?, let punkt?): trenner = komma > punkt ? "," : "."
}
var ganze = text
var bruch = ""
if let trenner, let stelle = text.lastIndex(of: trenner) {
ganze = String(text[text.startIndex..<stelle])
bruch = String(text[text.index(after: stelle)...])
}
ganze = ganze.filter(\.isNumber)
bruch = bruch.filter(\.isNumber)
// Mehr als zwei Nachkommastellen war keine Dezimalangabe, sondern ein
// Tausendertrenner ("1.234" sind 1234, nicht 1.23).
if bruch.count > 2 {
ganze += bruch
bruch = ""
}
bruch = String((bruch + "00").prefix(2))
guard let vorne = Int(ganze.isEmpty ? "0" : ganze), let hinten = Int(bruch) else {
return nil
}
let summe = vorne * 100 + hinten
return Geldbetrag(cents: negativ ? -summe : summe, waehrung: waehrung)
}
/// Zum Vorbelegen eines Eingabefelds: „12.50", ohne Währung und Trenner.
var alsEingabe: String {
let betrag = abs(cents)
return "\(betrag / 100),\(String(format: "%02d", betrag % 100))"
}
}
+94
View File
@@ -0,0 +1,94 @@
import SwiftUI
/// Der Hinweis über einer Liste, deren Daten aus dem Zwischenspeicher kommen.
///
/// Wichtiger als er aussieht: Ohne ihn zeigt die App im Flugmodus alte Zahlen,
/// die aussehen wie frische. Mit Datum, weil „offline" allein nicht sagt, ob
/// der Stand von vor fünf Minuten oder von vor drei Wochen ist.
struct StandHinweis: View {
let geholtAm: Date
var body: some View {
Label {
Text(String(format: String(localized: "stand.hinweis"),
geholtAm.formatted(.relative(presentation: .named))))
} icon: {
Image(systemName: "wifi.slash")
}
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.4))
}
}
/// Wenn gar nichts da ist: Fehler zeigen und, wo es Sinn ergibt, einen
/// Knopf zum Nochmalversuchen.
struct FehlerAnsicht: View {
let fehler: APIFehler
var nochmal: (() -> Void)?
var body: some View {
ContentUnavailableView {
Label(String(localized: "fehler.titel"), systemImage: symbol)
} description: {
Text(fehler.errorDescription ?? String(localized: "fehler.unbekannt"))
} actions: {
if let nochmal, fehler.lohntWiederholung {
Button(String(localized: "allgemein.nochmal"), action: nochmal)
.buttonStyle(.borderedProminent)
}
}
}
private var symbol: String {
switch fehler {
case .keineVerbindung: return "wifi.slash"
case .nichtAngemeldet: return "person.crop.circle.badge.exclamationmark"
case .nichtGefunden: return "questionmark.folder"
default: return "exclamationmark.triangle"
}
}
}
/// Eine Liste, die noch nichts enthält – aber nicht wegen eines Fehlers.
struct LeerAnsicht: View {
let titel: String
let erklaerung: String
var symbol: String = "tray"
var body: some View {
ContentUnavailableView {
Label(titel, systemImage: symbol)
} description: {
Text(erklaerung)
}
}
}
/// Setzt die drei Zustände für eine Liste zusammen, damit das nicht jede
/// Ansicht neu baut.
struct Inhalt<A: Sendable, Darstellung: View>: View {
let zustand: Ladezustand<A>
var nochmal: (() -> Void)?
@ViewBuilder let darstellung: (A) -> Darstellung
var body: some View {
switch zustand {
case .laedt:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .da(let wert):
darstellung(wert)
case .ausSpeicher(let wert, let wann, _):
VStack(spacing: 0) {
StandHinweis(geholtAm: wann)
darstellung(wert)
}
case .fehler(let fehler):
FehlerAnsicht(fehler: fehler, nochmal: nochmal)
}
}
}
+218
View File
@@ -0,0 +1,218 @@
import SwiftUI
/// Buchung anlegen oder ändern.
///
/// Eine Ansicht für beides: Die Felder sind dieselben, und zwei Formulare
/// nebeneinander wären genau die Stelle, an der sie auseinanderlaufen. Was
/// sich unterscheidet, steht in `BuchungFormular.Zweck`.
struct BuchungFormular: View {
enum Zweck: Equatable, Identifiable {
case neu
case aendern(Buchung)
/// Damit `.sheet(item:)` damit umgehen kann. Die ID der Buchung
/// genügt: Zwei Formulare für dieselbe Buchung gibt es nicht.
var id: Int {
switch self {
case .neu: return 0
case .aendern(let buchung): return buchung.id
}
}
}
let zweck: Zweck
/// Wird nach erfolgreichem Speichern oder Löschen gerufen – die Liste
/// dahinter muss sich neu laden.
var fertig: () -> Void
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
@State private var vorgang = Speichervorgang()
@State private var art: Buchungsart = .expense
@State private var datum = Date()
@State private var kontoId: Int?
@State private var zielkontoId: Int?
@State private var betragCents = 0
@State private var kategorieId: Int?
@State private var empfaenger = ""
@State private var zweckText = ""
@State private var notiz = ""
@State private var etiketten: Set<Int> = []
@State private var fragtNachLoeschen = false
@State private var vorbelegt = false
var body: some View {
NavigationStack {
Form {
Section {
Picker(String(localized: "buchungen.art"), selection: $art) {
ForEach(Buchungsart.allCases) { eine in
Text(eine.name).tag(eine)
}
}
.pickerStyle(.segmented)
DatePicker(String(localized: "buchung.datum"), selection: $datum,
displayedComponents: .date)
Betragsfeld(titel: String(localized: "buchung.betrag"),
waehrung: waehrung, cents: $betragCents)
}
Section {
KontoFeld(titel: String(localized: "buchung.konto"),
nachschlag: zustand.nachschlag, auswahl: $kontoId)
if art == .transfer {
KontoFeld(titel: String(localized: "buchung.zielkonto"),
nachschlag: zustand.nachschlag, ausser: kontoId,
auswahl: $zielkontoId)
} else {
KategorieFeld(titel: String(localized: "buchung.kategorie"),
nachschlag: zustand.nachschlag,
art: art == .income ? .income : .expense,
auswahl: $kategorieId)
}
} footer: {
if art == .transfer {
Text(String(localized: "buchung.uebertragErklaerung"))
}
}
Section {
TextField(String(localized: "buchung.empfaenger"), text: $empfaenger)
TextField(String(localized: "buchung.verwendungszweck"), text: $zweckText)
TextField(String(localized: "buchung.notiz"), text: $notiz, axis: .vertical)
.lineLimit(1...4)
}
Section(String(localized: "buchung.schlagwoerter")) {
SchlagwortFeld(nachschlag: zustand.nachschlag, gewaehlt: $etiketten)
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
if case .aendern = zweck {
Section {
Button(String(localized: "buchung.loeschen"), role: .destructive) {
fragtNachLoeschen = true
}
}
}
}
.navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.sichern")) {
Task { await sichere() }
}
.disabled(!istVollstaendig || vorgang.laeuft)
}
}
.interactiveDismissDisabled(vorgang.laeuft)
.confirmationDialog(String(localized: "buchung.loeschenFrage"),
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
Button(String(localized: "buchung.loeschen"), role: .destructive) {
Task { await loesche() }
}
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
}
.onAppear(perform: belegeVor)
}
}
private var titel: String {
switch zweck {
case .neu: return String(localized: "buchung.neu")
case .aendern: return String(localized: "buchung.aendern")
}
}
/// Die Währung des gewählten Kontos – Beträge stehen immer in ihr.
private var waehrung: String {
zustand.nachschlag.konto(kontoId)?.currency ?? zustand.einstellungen.basiswaehrung
}
/// Das Backend verlangt einen Betrag grösser null und bei einem Übertrag
/// ein Zielkonto. Hier zu prüfen erspart dem Nutzer eine Fehlermeldung
/// vom Server für etwas, das die App schon weiss.
private var istVollstaendig: Bool {
guard betragCents > 0, kontoId != nil else { return false }
if art == .transfer { return zielkontoId != nil && zielkontoId != kontoId }
return true
}
private func belegeVor() {
guard !vorbelegt else { return }
vorbelegt = true
guard case .aendern(let buchung) = zweck else {
kontoId = zustand.nachschlag.konten.first { !$0.isArchived }?.id
return
}
art = buchung.type
datum = buchung.date.datum
kontoId = buchung.accountId
zielkontoId = buchung.toAccountId
betragCents = abs(buchung.amountCents)
kategorieId = buchung.categoryId
empfaenger = buchung.payee ?? ""
zweckText = buchung.description ?? ""
notiz = buchung.notes ?? ""
etiketten = Set(buchung.tagIds)
}
private func sichere() async {
guard let client = zustand.client, let kontoId else { return }
let tag = Tagesdatum(datum)
// Bei einem Übertrag gibt es keine Kategorie – sie mitzuschicken
// hiesse, Geld einzuordnen, das nur die Tasche wechselt.
let kategorie = art == .transfer ? nil : kategorieId
let ziel = art == .transfer ? zielkontoId : nil
let geglueckt = await vorgang.fuehreAus {
switch zweck {
case .neu:
_ = try await client.legeBuchungAn(BuchungAnlegen(
type: art, date: tag, accountId: kontoId, toAccountId: ziel,
amountCents: betragCents, categoryId: kategorie,
payee: leerAlsNil(empfaenger), description: leerAlsNil(zweckText),
notes: leerAlsNil(notiz), tagIds: Array(etiketten)))
case .aendern(let buchung):
_ = try await client.aendereBuchung(buchung.id, BuchungAendern(
type: art, date: tag, accountId: kontoId, toAccountId: ziel,
amountCents: betragCents, categoryId: kategorie,
payee: leerAlsNil(empfaenger), description: leerAlsNil(zweckText),
notes: leerAlsNil(notiz), tagIds: Array(etiketten)))
}
}
if geglueckt {
fertig()
schliesse()
}
}
private func loesche() async {
guard let client = zustand.client, case .aendern(let buchung) = zweck else { return }
let geglueckt = await vorgang.fuehreAus {
try await client.loescheBuchung(buchung.id)
}
if geglueckt {
fertig()
schliesse()
}
}
/// Ein leeres Feld ist keine Angabe, sondern das Fehlen einer. Leerzeichen
/// als Empfänger zu speichern wäre eine Angabe, die niemand gemacht hat.
private func leerAlsNil(_ text: String) -> String? {
let sauber = text.trimmingCharacters(in: .whitespacesAndNewlines)
return sauber.isEmpty ? nil : sauber
}
}
+238
View File
@@ -0,0 +1,238 @@
import SwiftUI
/// Buchungen und die Paarliste in einem Stand.
///
/// Beides gehört zusammen: Ohne die Paare zeigt die Liste beide Beine eines
/// Übertrags als zwei Zeilen und damit dasselbe Geld zweimal.
struct Buchungsstand: Codable, Sendable, Hashable {
let buchungen: [Buchung]
let paare: [String: Uebertragsverknuepfung]
}
/// Die Buchungsliste.
///
/// Gesucht und gefiltert wird im **Server** – seit der Backend-Ergänzung
/// kennt `GET /transactions` `q`, `tag_id` und `subscription_id`. Die
/// Weboberfläche lädt bis zu 5000 Zeilen und siebt im Browser; auf dem
/// Telefon wäre das der Unterschied zwischen „lädt" und „benutzbar".
struct BuchungsListe: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<Buchungsstand>?
@State private var suche = ""
@State private var konto: Int?
@State private var art: Buchungsart?
/// Was zuletzt wirklich abgerufen wurde – damit das Tippen nicht je
/// Buchstabe einen Abruf auslöst.
@State private var abgerufen = ""
@State private var formular: BuchungFormular.Zweck?
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lade() } }) { stand in
liste(stand)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "reiter.buchungen"))
.searchable(text: $suche, prompt: String(localized: "buchungen.suchen"))
// Erst beim Absenden abrufen. Je Tastendruck zu fragen hiesse, dem
// Server bei „Supermarkt" zehn Abrufe zu schicken, von denen neun
// niemanden interessieren.
.onSubmit(of: .search) { Task { await lade() } }
.onChange(of: suche) { _, neu in
if neu.isEmpty, !abgerufen.isEmpty { Task { await lade() } }
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) { filterMenue }
ToolbarItem(placement: .topBarTrailing) {
Button {
formular = .neu
} label: {
Image(systemName: "plus")
}
.accessibilityLabel(String(localized: "buchung.neu"))
}
}
.sheet(item: $formular) { zweck in
BuchungFormular(zweck: zweck) { Task { await lade() } }
.environment(zustand)
}
.task {
guard lader == nil else { return }
await lade()
}
}
private var filterMenue: some View {
Menu {
Picker(String(localized: "buchungen.konto"), selection: $konto) {
Text(String(localized: "buchungen.alleKonten")).tag(Int?.none)
ForEach(zustand.nachschlag.konten.filter { !$0.isArchived }) { eines in
Text(eines.name).tag(Int?.some(eines.id))
}
}
Picker(String(localized: "buchungen.art"), selection: $art) {
Text(String(localized: "buchungen.alleArten")).tag(Buchungsart?.none)
ForEach(Buchungsart.allCases) { eine in
Text(eine.name).tag(Buchungsart?.some(eine))
}
}
} label: {
Image(systemName: gefiltert ? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
.onChange(of: konto) { _, _ in Task { await lade() } }
.onChange(of: art) { _, _ in Task { await lade() } }
}
private var gefiltert: Bool { konto != nil || art != nil || !abgerufen.isEmpty }
private func lade() async {
guard let client = zustand.client else { return }
let text = suche.trimmingCharacters(in: .whitespacesAndNewlines)
let gewaehltesKonto = konto
let gewaehlteArt = art
abgerufen = text
// Der Schlüssel trägt die Filter: Sonst überschriebe eine gefilterte
// Liste den Offline-Stand der vollständigen.
let schluessel = "transactions_\(gewaehltesKonto ?? 0)_\(gewaehlteArt?.rawValue ?? "alle")_\(text)"
let neuer = Lader<Buchungsstand>(schluessel: schluessel) {
async let buchungen = client.buchungen(konto: gewaehltesKonto, art: gewaehlteArt,
suche: text.isEmpty ? nil : text)
async let paare = client.uebertragsverknuepfungen()
return try await Buchungsstand(buchungen: buchungen, paare: paare)
}
lader = neuer
await neuer.lade()
}
@ViewBuilder
private func liste(_ stand: Buchungsstand) -> some View {
let zeilen = Buchungszeile.zeilen(aus: stand.buchungen, paare: stand.paare)
if zeilen.isEmpty {
LeerAnsicht(titel: String(localized: "buchungen.leerTitel"),
erklaerung: String(localized: gefiltert ? "buchungen.leerGefiltert"
: "buchungen.leerErklaerung"),
symbol: "list.bullet.rectangle")
} else {
List {
ForEach(nachTagen(zeilen), id: \.tag) { gruppe in
Section(gruppe.tag.formatiert()) {
ForEach(gruppe.zeilen) { zeile in
Button {
formular = .aendern(zeile.buchung)
} label: {
BuchungsZeile(zeile: zeile, nachschlag: zustand.nachschlag)
}
.buttonStyle(.plain)
}
}
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
}
}
/// Nach Tagen gruppiert, Reihenfolge wie geliefert (Datum absteigend).
private func nachTagen(_ zeilen: [Buchungszeile]) -> [(tag: Tagesdatum, zeilen: [Buchungszeile])] {
var reihenfolge: [Tagesdatum] = []
var nachTag: [Tagesdatum: [Buchungszeile]] = [:]
for zeile in zeilen {
let tag = zeile.buchung.date
if nachTag[tag] == nil { reihenfolge.append(tag) }
nachTag[tag, default: []].append(zeile)
}
return reihenfolge.map { (tag: $0, zeilen: nachTag[$0] ?? []) }
}
}
// MARK: - Eine Zeile
private struct BuchungsZeile: View {
let zeile: Buchungszeile
let nachschlag: Nachschlag
var body: some View {
HStack(spacing: 10) {
Symbolplakette(symbol: symbol, farbe: farbe, groesse: 34)
VStack(alignment: .leading, spacing: 2) {
Text(ueberschrift)
.font(.callout)
.lineLimit(1)
HStack(spacing: 6) {
Text(unterzeile)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
ForEach(zeile.buchung.tagIds, id: \.self) { tagId in
if let etikett = nachschlag.schlagwort(tagId) {
Text(etikett.name)
.font(.caption2)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Color(hex: etikett.color).opacity(0.18), in: Capsule())
}
}
}
}
Spacer(minLength: 8)
Text(zeile.buchung.betrag.formatiert(vorzeichen: .ohne))
.font(.callout.monospacedDigit())
.foregroundStyle(betragsfarbe)
}
.padding(.vertical, 2)
}
private var kategorie: Kategorie? { nachschlag.kategorie(zeile.buchung.categoryId) }
private var symbol: String {
if zeile.istUebertrag { return "arrow-left-right" }
return kategorie?.icon ?? "more-horizontal"
}
private var farbe: String {
if zeile.istUebertrag { return "#6b7480" }
return kategorie?.color ?? "#6b7480"
}
/// Bei einem Übertrag sagt die Zeile, wohin – „Übertrag" allein sagt
/// nichts. Sonst steht da der Empfänger.
private var ueberschrift: String {
guard zeile.istUebertrag else { return zeile.buchung.ueberschrift }
let von = nachschlag.kontoname(zeile.vonKonto)
guard let nach = zeile.nachKonto else {
return String(localized: "buchung.uebertrag")
}
return "\(von) → \(nachschlag.kontoname(nach))"
}
private var unterzeile: String {
if zeile.istUebertrag {
return zeile.buchung.unterschrift ?? String(localized: "buchung.uebertrag")
}
var teile: [String] = []
if let kategorie { teile.append(kategorie.name) }
else { teile.append(String(localized: "buchung.ohneKategorie")) }
teile.append(nachschlag.kontoname(zeile.buchung.accountId))
return teile.joined(separator: " · ")
}
/// Grün bei Einnahme, neutral beim Übertrag: Da verlässt kein Geld den
/// Haushalt, es wechselt nur die Tasche.
private var betragsfarbe: Color {
if zeile.istUebertrag { return .secondary }
return zeile.buchung.type == .income ? .green : .primary
}
}
+364
View File
@@ -0,0 +1,364 @@
import SwiftUI
/// Soll gegen Ist, Monat für Monat.
///
/// Geblättert wird über `prev` und `next` aus der Antwort – welcher Monat auf
/// welchen folgt, hängt am eingestellten Budget-Starttag und ist nichts, was
/// der Client ausrechnen sollte.
struct BudgetAnsicht: View {
@Environment(AppZustand.self) private var zustand
/// `nil` heisst „der laufende Monat". Beim Blättern steht hier ein Label.
@State private var label: String?
@State private var lader: Lader<Budgetmonat>?
@State private var bearbeitet: ZeileZumBearbeiten?
@State private var vorgang = Speichervorgang()
@State private var fragtNachZuruecksetzen = false
/// Welche Zeile wohin gesetzt wird – beides zusammen, damit das Blatt
/// weiss, ob es den Monat oder die Vorlage ändert.
struct ZeileZumBearbeiten: Identifiable, Equatable {
let kategorie: Budgetkategorie
let ziel: BudgetZeileBearbeiten.Ziel
var id: Int { kategorie.categoryId }
}
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { monat in
liste(monat)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "reiter.budget"))
.navigationBarTitleDisplayMode(.inline)
.task(id: label) { await ladeMonat() }
}
private func ladeMonat() async {
guard let client = zustand.client else { return }
let gewaehlt = label
let neuer = Lader<Budgetmonat>(schluessel: "budget/\(gewaehlt ?? "current")") {
try await client.budget(gewaehlt)
}
lader = neuer
await neuer.lade()
}
@ViewBuilder
private func liste(_ monat: Budgetmonat) -> some View {
List {
Section {
MonatsKopf(monat: monat)
}
if !monat.income.isEmpty {
Section(String(localized: "budget.einnahmen")) {
ForEach(monat.income) { posten in
EinnahmeZeile(posten: posten, waehrung: monat.baseCurrency)
}
}
}
ForEach(monat.groups) { gruppe in
Section {
ForEach(gruppe.categories) { kategorie in
Button {
bearbeitet = ZeileZumBearbeiten(kategorie: kategorie,
ziel: .monat(monat.label))
} label: {
KategorieZeile(kategorie: kategorie, waehrung: monat.baseCurrency)
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button {
bearbeitet = ZeileZumBearbeiten(kategorie: kategorie,
ziel: .vorlage)
} label: {
Label(String(localized: "budget.vorlage"), systemImage: "doc.text")
}
.tint(.marke)
}
}
} header: {
GruppenKopf(gruppe: gruppe, waehrung: monat.baseCurrency)
}
}
if !monat.vertraege.istLeer {
Section {
VertragsHinweis(reste: monat.vertraege, waehrung: monat.baseCurrency)
}
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
label = monat.prev
} label: {
Image(systemName: "chevron.left")
}
.accessibilityLabel(String(localized: "budget.vorigerMonat"))
}
ToolbarItem(placement: .principal) {
Text(monatsname(monat))
.font(.headline)
}
ToolbarItem(placement: .topBarTrailing) {
Button {
label = monat.next
} label: {
Image(systemName: "chevron.right")
}
.accessibilityLabel(String(localized: "budget.naechsterMonat"))
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
Task { await uebernimm { try await $0.holeVorlageInMonat(monat.label) } }
} label: {
Label(String(localized: "budget.vorlageHolen"),
systemImage: "arrow.down.doc")
}
Button {
Task { await uebernimm { try await $0.macheMonatZurVorlage(monat.label) } }
} label: {
Label(String(localized: "budget.alsVorlageSetzen"),
systemImage: "arrow.up.doc")
}
if monat.customized {
Button(role: .destructive) {
fragtNachZuruecksetzen = true
} label: {
Label(String(localized: "budget.zuruecksetzen"),
systemImage: "arrow.uturn.backward")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
.disabled(vorgang.laeuft)
}
}
.sheet(item: $bearbeitet) { eintrag in
BudgetZeileBearbeiten(ziel: eintrag.ziel, kategorie: eintrag.kategorie,
waehrung: monat.baseCurrency) {
Task { await lader?.aktualisiere() }
}
.environment(zustand)
}
.confirmationDialog(String(localized: "budget.zuruecksetzenFrage"),
isPresented: $fragtNachZuruecksetzen, titleVisibility: .visible) {
Button(String(localized: "budget.zuruecksetzen"), role: .destructive) {
Task { await uebernimm { try await $0.setzeBudgetMonatZurueck(monat.label) } }
}
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
} message: {
Text(String(localized: "budget.zuruecksetzenErklaerung"))
}
.alert(String(localized: "fehler.titel"),
isPresented: .constant(vorgang.fehler != nil)) {
Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil }
} message: {
Text(vorgang.fehler ?? "")
}
}
/// Die drei Knöpfe im Menü tun dasselbe: etwas schreiben, dann neu laden.
private func uebernimm(_ arbeit: @escaping (CertainClient) async throws -> Void) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await arbeit(client) }) {
await lader?.aktualisiere()
}
}
/// „Oktober 2026" statt „2026-10".
private func monatsname(_ monat: Budgetmonat) -> String {
monat.start.datum.formatted(.dateTime.month(.wide).year())
}
}
// MARK: - Bausteine
private struct MonatsKopf: View {
let monat: Budgetmonat
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(Geldbetrag(cents: monat.restCents, waehrung: monat.baseCurrency).formatiert())
.font(.title.weight(.semibold).monospacedDigit())
.foregroundStyle(monat.restCents < 0 ? .red : .primary)
Text(String(localized: "budget.uebrig"))
.font(.subheadline)
.foregroundStyle(.secondary)
}
ProgressView(value: monat.anteilVerbraucht)
.tint(monat.restCents < 0 ? .red : .accentColor)
HStack {
Text(String(format: String(localized: "budget.vonGeplant"),
Geldbetrag(cents: monat.totals.actualExpense,
waehrung: monat.baseCurrency).formatiert(),
Geldbetrag(cents: monat.totals.plannedExpense,
waehrung: monat.baseCurrency).formatiert()))
Spacer()
if monat.customized {
// Wer das nicht sieht, wundert sich, warum die Vorlage
// andere Zahlen zeigt als dieser Monat.
Text(String(localized: "budget.angepasst"))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.quaternary, in: Capsule())
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
private struct GruppenKopf: View {
let gruppe: Budgetgruppe
let waehrung: String
var body: some View {
HStack(spacing: 8) {
Image(systemName: Symbole.sf(gruppe.icon))
.foregroundStyle(Color(hex: gruppe.color))
Text(gruppe.name)
Spacer(minLength: 8)
Text(Geldbetrag(cents: gruppe.actualCents, waehrung: waehrung).formatiert())
.monospacedDigit()
.foregroundStyle(gruppe.over && !gruppe.good ? .red : .secondary)
Text("/")
.foregroundStyle(.tertiary)
Text(Geldbetrag(cents: gruppe.plannedCents, waehrung: waehrung).formatiert())
.monospacedDigit()
.foregroundStyle(.secondary)
}
.textCase(nil)
.font(.footnote)
}
}
private struct KategorieZeile: View {
let kategorie: Budgetkategorie
let waehrung: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color, groesse: 30)
VStack(alignment: .leading, spacing: 1) {
Text(kategorie.name)
.font(.callout)
.lineLimit(1)
if let unterzeile {
Text(unterzeile)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 1) {
Text(Geldbetrag(cents: kategorie.actualCents, waehrung: waehrung).formatiert())
.font(.callout.monospacedDigit())
.foregroundStyle(warnt ? .red : .primary)
Text(String(format: String(localized: "budget.vonBetrag"),
Geldbetrag(cents: kategorie.plannedCents,
waehrung: waehrung).formatiert()))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
if kategorie.plannedCents > 0 || kategorie.actualCents > 0 {
ProgressView(value: kategorie.anteilVerbraucht)
.tint(warnt ? .red : (kategorie.good ? .green : Color(hex: kategorie.color)))
}
}
.padding(.vertical, 2)
}
/// Rot nur bei echter Überschreitung: Mehr sparen als geplant ist keine.
private var warnt: Bool { kategorie.over && !kategorie.good }
/// Woher der Soll-Wert kommt, wenn er sich nicht einfach ablesen lässt.
private var unterzeile: String? {
guard kategorie.vertraegeAnzahl > 0 else { return nil }
let ausVertraegen = Geldbetrag(cents: kategorie.ausVertraegenCents,
waehrung: waehrung).formatiert()
if kategorie.istBerechnet {
return String(format: String(localized: "budget.ausVertraegenBerechnet"),
kategorie.vertraegeAnzahl, ausVertraegen)
}
return String(format: String(localized: "budget.ausVertraegenHinweis"),
kategorie.vertraegeAnzahl, ausVertraegen)
}
}
private struct EinnahmeZeile: View {
let posten: GeplanteEinnahme
let waehrung: String
var body: some View {
HStack {
Text(posten.label)
.font(.callout)
Spacer(minLength: 8)
Text(Geldbetrag(cents: posten.receivedCents, waehrung: waehrung).formatiert())
.font(.callout.monospacedDigit())
.foregroundStyle(posten.receivedCents >= posten.plannedCents ? .green : .primary)
Text("/")
.foregroundStyle(.tertiary)
Text(Geldbetrag(cents: posten.plannedCents, waehrung: waehrung).formatiert())
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
/// Verträge, die im Budget fehlen – und Währungen, für die kein Kurs vorlag.
///
/// Ohne diesen Hinweis stimmen die Summen still nicht. Genau davor warnt das
/// Backend mit den `ohne_kurs`-Listen.
private struct VertragsHinweis: View {
let reste: Vertragsreste
let waehrung: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if reste.ohneKategorieAnzahl > 0 {
Label {
Text(String(format: String(localized: "budget.vertraegeOhneKategorie"),
reste.ohneKategorieAnzahl,
Geldbetrag(cents: reste.ohneKategorieCents,
waehrung: waehrung).formatiert()))
} icon: {
Image(systemName: "questionmark.circle")
}
}
if !reste.ohneKurs.isEmpty {
Label {
Text(String(format: String(localized: "budget.ohneKurs"),
reste.ohneKurs.joined(separator: ", ")))
} icon: {
Image(systemName: "exclamationmark.triangle")
}
.foregroundStyle(.orange)
}
}
.font(.footnote)
}
}
+130
View File
@@ -0,0 +1,130 @@
import SwiftUI
/// Eine Budget-Zeile setzen – für den Monat oder für die Vorlage.
///
/// Dieselbe Maske für beides, weil das Backend dieselben drei Angaben
/// entgegennimmt (`planned_cents`, `modus`, `zuschlag_cents`) und sie über
/// `uebernimm` durch dieselbe Regel schickt. Was sich unterscheidet, ist nur
/// der Endpunkt und der Satz darunter.
struct BudgetZeileBearbeiten: View {
enum Ziel: Equatable {
/// Wirkt nur auf diesen Monat; er gilt danach als angepasst.
case monat(String)
/// Wirkt auf alle Monate, die nicht eigens angepasst sind.
case vorlage
}
let ziel: Ziel
let kategorie: Budgetkategorie
let waehrung: String
var fertig: () -> Void
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
@State private var vorgang = Speichervorgang()
@State private var istBerechnet = false
@State private var geplantCents = 0
@State private var zuschlagCents = 0
@State private var vorbelegt = false
var body: some View {
NavigationStack {
Form {
Section {
HStack(spacing: 10) {
Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color)
Text(kategorie.name)
.font(.headline)
}
}
Section {
Toggle(String(localized: "budget.ausVertraegenRechnen"),
isOn: $istBerechnet)
} footer: {
Text(istBerechnet ? String(localized: "budget.berechnetErklaerung")
: String(localized: "budget.festErklaerung"))
}
Section {
if istBerechnet {
// Bei „berechnet" ist die hingeschriebene Zahl nicht
// der Soll-Betrag, sondern nur der Zuschlag obendrauf.
LabeledContent(String(localized: "budget.ausVertraegen"),
value: Geldbetrag(cents: kategorie.ausVertraegenCents,
waehrung: waehrung).formatiert())
Betragsfeld(titel: String(localized: "budget.zuschlag"),
waehrung: waehrung, cents: $zuschlagCents)
LabeledContent(String(localized: "budget.ergibt"),
value: Geldbetrag(cents: kategorie.ausVertraegenCents + zuschlagCents,
waehrung: waehrung).formatiert())
.fontWeight(.medium)
} else {
Betragsfeld(titel: String(localized: "budget.geplant"),
waehrung: waehrung, cents: $geplantCents)
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.navigationTitle(zielTitel)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.sichern")) {
Task { await sichere() }
}
.disabled(vorgang.laeuft)
}
}
.interactiveDismissDisabled(vorgang.laeuft)
.onAppear {
guard !vorbelegt else { return }
vorbelegt = true
istBerechnet = kategorie.istBerechnet
geplantCents = kategorie.plannedCents
zuschlagCents = kategorie.zuschlagCents
}
}
}
private var zielTitel: String {
switch ziel {
case .monat: return String(localized: "budget.monatSetzen")
case .vorlage: return String(localized: "budget.vorlageSetzen")
}
}
private func sichere() async {
guard let client = zustand.client else { return }
let modus = istBerechnet ? "berechnet" : "fest"
// Bei „berechnet" bleibt planned_cents unberührt: Das Backend liest
// es dann nicht, und es zu überschreiben verlöre den Wert, auf den
// ein Zurückschalten auf „fest" zurückfallen soll.
let geplant = istBerechnet ? nil : geplantCents
let zuschlag = istBerechnet ? zuschlagCents : nil
let geglueckt = await vorgang.fuehreAus {
switch ziel {
case .monat(let label):
try await client.setzeBudgetMonat(label, kategorie: kategorie.categoryId,
geplantCents: geplant, modus: modus,
zuschlagCents: zuschlag)
case .vorlage:
try await client.setzeBudgetVorlage(kategorie: kategorie.categoryId,
geplantCents: geplant, modus: modus,
zuschlagCents: zuschlag)
}
}
if geglueckt {
fertig()
schliesse()
}
}
}
+756
View File
@@ -0,0 +1,756 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5FA8AE870EDCB51232CA9E /* AboListe.swift */; };
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4119B85EFF3FB38E84FAA913 /* Konto.swift */; };
18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */; };
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; };
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */; };
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */; };
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; };
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; };
2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */; };
2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */; };
3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */; };
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; };
428AA2C2634A6F94D2B30158 /* BudgetZeileBearbeiten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */; };
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287D314176EBA5E888605F5F /* Geldbetrag.swift */; };
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */; };
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; };
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; };
5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */; };
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */; };
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */ = {isa = PBXBuildFile; fileRef = 3137E525D1ADE3DB2F8E45A8 /* antworten.json */; };
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */; };
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */; };
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51DE5BDB909910D586F819F /* Budget.swift */; };
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */; };
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8BE152C9F91584900796ADA /* Grundmodelle.swift */; };
90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F14B825905BA99472E4B289 /* BankAnsicht.swift */; };
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A150CAB4967A479B506A997F /* BuchungsListe.swift */; };
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; };
A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = C841BA87849B2A461449BAF5 /* KategorieFormular.swift */; };
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; };
AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */; };
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; };
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = D42C757A9D818A524C4CAC8A /* AppIcon.icon */; };
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; };
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 778040646D05B1DEEE3BF727 /* Formular.swift */; };
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; };
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */; };
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; };
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */; };
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; };
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFABF1A481445B1CD960C993 /* AboFormular.swift */; };
F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; };
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
2D43432195E8A8F77E79DF49 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = E5DF7640265CD3906602698E /* Project object */;
proxyType = 1;
remoteGlobalIDString = 273DB28E6F6B8944B4879A06;
remoteInfo = Certain;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = "<group>"; };
0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Arbeitsliste.swift; sourceTree = "<group>"; };
0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetZeileBearbeiten.swift; sourceTree = "<group>"; };
13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = "<group>"; };
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = "<group>"; };
1E6FE0925FC71A03818FFF72 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
1F14B825905BA99472E4B289 /* BankAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BankAnsicht.swift; sourceTree = "<group>"; };
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrundlagenTests.swift; sourceTree = "<group>"; };
287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = "<group>"; };
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nebenraeume.swift; sourceTree = "<group>"; };
3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = "<group>"; };
327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = "<group>"; };
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SperrAnsicht.swift; sourceTree = "<group>"; };
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuswahlBlaetter.swift; sourceTree = "<group>"; };
4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; };
416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; };
451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = "<group>"; };
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontoFormular.swift; sourceTree = "<group>"; };
4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArbeitslisteAnsicht.swift; sourceTree = "<group>"; };
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswortWechselAnsicht.swift; sourceTree = "<group>"; };
5D3341CDA026CDF55D621A8A /* CertainTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CertainTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5D6CEAB46FB8955115A3064B /* Endpunkte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpunkte.swift; sourceTree = "<group>"; };
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eingaben.swift; sourceTree = "<group>"; };
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zeitangaben.swift; sourceTree = "<group>"; };
776F26865B849D2318B6819B /* Zustandsansichten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zustandsansichten.swift; sourceTree = "<group>"; };
777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorienAnsicht.swift; sourceTree = "<group>"; };
778040646D05B1DEEE3BF727 /* Formular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formular.swift; sourceTree = "<group>"; };
77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = "<group>"; };
78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = "<group>"; };
89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsAnsicht.swift; sourceTree = "<group>"; };
8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainApp.swift; sourceTree = "<group>"; };
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboListe.swift; sourceTree = "<group>"; };
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetAnsicht.swift; sourceTree = "<group>"; };
A150CAB4967A479B506A997F /* BuchungsListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungsListe.swift; sourceTree = "<group>"; };
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiFaktorAnsicht.swift; sourceTree = "<group>"; };
A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungFormular.swift; sourceTree = "<group>"; };
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAnsicht.swift; sourceTree = "<group>"; };
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kategorie.swift; sourceTree = "<group>"; };
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dashboard.swift; sourceTree = "<group>"; };
C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zwischenspeicher.swift; sourceTree = "<group>"; };
C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIFehler.swift; sourceTree = "<group>"; };
C5F948B06FF2E65118C82636 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
C841BA87849B2A461449BAF5 /* KategorieFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorieFormular.swift; sourceTree = "<group>"; };
D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiterFaktorAnsicht.swift; sourceTree = "<group>"; };
D42C757A9D818A524C4CAC8A /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppZustand.swift; sourceTree = "<group>"; };
DBDF1014AC23C916BE571DDC /* Lader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lader.swift; sourceTree = "<group>"; };
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = "<group>"; };
E51DE5BDB909910D586F819F /* Budget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Budget.swift; sourceTree = "<group>"; };
E8CBA42A030509615199DD45 /* Abo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Abo.swift; sourceTree = "<group>"; };
EFABF1A481445B1CD960C993 /* AboFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboFormular.swift; sourceTree = "<group>"; };
F8BE152C9F91584900796ADA /* Grundmodelle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grundmodelle.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
0EDDD922C06B1465AD13C782 /* Texte */ = {
isa = PBXGroup;
children = (
BDF076C7F062202810699EE9 /* Localizable.strings */,
);
path = Texte;
sourceTree = "<group>";
};
298E43BBA9248E5A9779DA98 /* Konten */ = {
isa = PBXGroup;
children = (
78AD0B83BDFDF777917C9382 /* KontenListe.swift */,
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */,
);
path = Konten;
sourceTree = "<group>";
};
524FD85EDE7C4DE5ABE9A8E5 /* Tests */ = {
isa = PBXGroup;
children = (
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */,
7DD1C597D594D0F965FF1547 /* Fixtures */,
);
path = Tests;
sourceTree = "<group>";
};
56CB7B1C18136BFD83ACADBD /* Einstellungen */ = {
isa = PBXGroup;
children = (
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */,
);
path = Einstellungen;
sourceTree = "<group>";
};
5E720D91F0CF9DBDA975A959 /* Bausteine */ = {
isa = PBXGroup;
children = (
451BA41D9B53FB6B511AF734 /* Darstellung.swift */,
778040646D05B1DEEE3BF727 /* Formular.swift */,
287D314176EBA5E888605F5F /* Geldbetrag.swift */,
776F26865B849D2318B6819B /* Zustandsansichten.swift */,
);
path = Bausteine;
sourceTree = "<group>";
};
7DD1C597D594D0F965FF1547 /* Fixtures */ = {
isa = PBXGroup;
children = (
3137E525D1ADE3DB2F8E45A8 /* antworten.json */,
);
path = Fixtures;
sourceTree = "<group>";
};
80EFC9A43394C64E9AEEF5A1 /* Modelle */ = {
isa = PBXGroup;
children = (
E8CBA42A030509615199DD45 /* Abo.swift */,
0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */,
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
E51DE5BDB909910D586F819F /* Budget.swift */,
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */,
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */,
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
);
path = Modelle;
sourceTree = "<group>";
};
AE11D36E2DFAC34130E2868F /* Dashboard */ = {
isa = PBXGroup;
children = (
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */,
);
path = Dashboard;
sourceTree = "<group>";
};
B4505C6E527A0020B80AFF04 /* Anmeldung */ = {
isa = PBXGroup;
children = (
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */,
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
);
path = Anmeldung;
sourceTree = "<group>";
};
BC82A6A323BDD2AD8325BEA2 = {
isa = PBXGroup;
children = (
D42C757A9D818A524C4CAC8A /* AppIcon.icon */,
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
B4505C6E527A0020B80AFF04 /* Anmeldung */,
BEE6BC97D865C2F0C4EB80C4 /* App */,
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */,
5E720D91F0CF9DBDA975A959 /* Bausteine */,
D99101183ED9DA14C8D22722 /* Buchungen */,
C4B3E718A814E0236A12EB86 /* Budget */,
AE11D36E2DFAC34130E2868F /* Dashboard */,
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
298E43BBA9248E5A9779DA98 /* Konten */,
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */,
DB51F54CC077E85C286AD9DC /* Netzwerk */,
524FD85EDE7C4DE5ABE9A8E5 /* Tests */,
0EDDD922C06B1465AD13C782 /* Texte */,
C36A0DD814B82B9DA47A4D58 /* Products */,
);
sourceTree = "<group>";
};
BEE6BC97D865C2F0C4EB80C4 /* App */ = {
isa = PBXGroup;
children = (
D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */,
8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */,
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */,
);
path = App;
sourceTree = "<group>";
};
C36A0DD814B82B9DA47A4D58 /* Products */ = {
isa = PBXGroup;
children = (
416FD5B020E78140037306C8 /* Certain.app */,
5D3341CDA026CDF55D621A8A /* CertainTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
C4B3E718A814E0236A12EB86 /* Budget */ = {
isa = PBXGroup;
children = (
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */,
0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */,
);
path = Budget;
sourceTree = "<group>";
};
CB7098BB5F4AEFE48BA9BB36 /* Abos */ = {
isa = PBXGroup;
children = (
EFABF1A481445B1CD960C993 /* AboFormular.swift */,
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */,
);
path = Abos;
sourceTree = "<group>";
};
D99101183ED9DA14C8D22722 /* Buchungen */ = {
isa = PBXGroup;
children = (
A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */,
A150CAB4967A479B506A997F /* BuchungsListe.swift */,
);
path = Buchungen;
sourceTree = "<group>";
};
DB51F54CC077E85C286AD9DC /* Netzwerk */ = {
isa = PBXGroup;
children = (
13C14F1D9CEF39FF3E095239 /* Ablage.swift */,
C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */,
77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */,
5D6CEAB46FB8955115A3064B /* Endpunkte.swift */,
DBDF1014AC23C916BE571DDC /* Lader.swift */,
C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */,
);
path = Netzwerk;
sourceTree = "<group>";
};
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */ = {
isa = PBXGroup;
children = (
1F14B825905BA99472E4B289 /* BankAnsicht.swift */,
C841BA87849B2A461449BAF5 /* KategorieFormular.swift */,
777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */,
89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */,
D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */,
);
path = Nebenraeume;
sourceTree = "<group>";
};
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */ = {
isa = PBXGroup;
children = (
4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */,
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */,
);
path = Arbeitsliste;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
273DB28E6F6B8944B4879A06 /* Certain */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5FB7AD5694029CE8047B30A6 /* Build configuration list for PBXNativeTarget "Certain" */;
buildPhases = (
AD804F932D01445AA1EBDDF6 /* Sources */,
0C89482C2F6A53CC6DF65D35 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Certain;
packageProductDependencies = (
);
productName = Certain;
productReference = 416FD5B020E78140037306C8 /* Certain.app */;
productType = "com.apple.product-type.application";
};
30BF803561347EA12325BCAA /* CertainTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = B7C4A363393DD132E27BF360 /* Build configuration list for PBXNativeTarget "CertainTests" */;
buildPhases = (
104B97AFE88042A9CF83B9C7 /* Sources */,
5E084A3C9ED86572E29E01B7 /* Resources */,
);
buildRules = (
);
dependencies = (
183CFCE6D6F6769B7A0CFE43 /* PBXTargetDependency */,
);
name = CertainTests;
packageProductDependencies = (
);
productName = CertainTests;
productReference = 5D3341CDA026CDF55D621A8A /* CertainTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
E5DF7640265CD3906602698E /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
273DB28E6F6B8944B4879A06 = {
DevelopmentTeam = PP34X97WS3;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = FE4C13F0F9703E456BEE9496 /* Build configuration list for PBXProject "Certain" */;
developmentRegion = de;
hasScannedForEncodings = 0;
knownRegions = (
Base,
de,
en,
);
mainGroup = BC82A6A323BDD2AD8325BEA2;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = C36A0DD814B82B9DA47A4D58 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
273DB28E6F6B8944B4879A06 /* Certain */,
30BF803561347EA12325BCAA /* CertainTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
0C89482C2F6A53CC6DF65D35 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */,
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
5E084A3C9ED86572E29E01B7 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
104B97AFE88042A9CF83B9C7 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
AD804F932D01445AA1EBDDF6 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
F414F982412C79242CADB473 /* Ablage.swift in Sources */,
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */,
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */,
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */,
2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */,
2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */,
AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */,
90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */,
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */,
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */,
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */,
428AA2C2634A6F94D2B30158 /* BudgetZeileBearbeiten.swift in Sources */,
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */,
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */,
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */,
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */,
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */,
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */,
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */,
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */,
5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */,
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */,
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */,
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */,
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */,
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */,
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */,
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */,
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */,
18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */,
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
183CFCE6D6F6769B7A0CFE43 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 273DB28E6F6B8944B4879A06 /* Certain */;
targetProxy = 2D43432195E8A8F77E79DF49 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
BDF076C7F062202810699EE9 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
C5F948B06FF2E65118C82636 /* de */,
1E6FE0925FC71A03818FFF72 /* en */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
0C1990D56F8E2B65D3DB0A44 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Certain;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
507EC2B7E4B0269B65F8F2A2 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
6E14A372460452CBE33D2F62 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.CertainTests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Certain.app/Certain";
};
name = Debug;
};
78BBFF0B27B6614A8955859E /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = Release;
};
9611AED428CDAEAF407DCF13 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.CertainTests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Certain.app/Certain";
};
name = Release;
};
CADCD521080B321D3D652106 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Certain;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
5FB7AD5694029CE8047B30A6 /* Build configuration list for PBXNativeTarget "Certain" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CADCD521080B321D3D652106 /* Debug */,
0C1990D56F8E2B65D3DB0A44 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
B7C4A363393DD132E27BF360 /* Build configuration list for PBXNativeTarget "CertainTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6E14A372460452CBE33D2F62 /* Debug */,
9611AED428CDAEAF407DCF13 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
FE4C13F0F9703E456BEE9496 /* Build configuration list for PBXProject "Certain" */ = {
isa = XCConfigurationList;
buildConfigurations = (
507EC2B7E4B0269B65F8F2A2 /* Debug */,
78BBFF0B27B6614A8955859E /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = E5DF7640265CD3906602698E /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "273DB28E6F6B8944B4879A06"
BuildableName = "Certain.app"
BlueprintName = "Certain"
ReferencedContainer = "container:Certain.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "273DB28E6F6B8944B4879A06"
BuildableName = "Certain.app"
BlueprintName = "Certain"
ReferencedContainer = "container:Certain.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "30BF803561347EA12325BCAA"
BuildableName = "CertainTests.xctest"
BlueprintName = "CertainTests"
ReferencedContainer = "container:Certain.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "273DB28E6F6B8944B4879A06"
BuildableName = "Certain.app"
BlueprintName = "Certain"
ReferencedContainer = "container:Certain.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "273DB28E6F6B8944B4879A06"
BuildableName = "Certain.app"
BlueprintName = "Certain"
ReferencedContainer = "container:Certain.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+278
View File
@@ -0,0 +1,278 @@
import SwiftUI
/// Alles, was das Dashboard braucht, in einem Abruf.
///
/// Vier Endpunkte nebeneinander statt nacheinander – sie wissen nichts
/// voneinander. Zusammen in einem Wert, damit `Lader` sie als einen Stand
/// merkt: Vier getrennte Zwischenspeicher könnten offline aus vier
/// verschiedenen Momenten stammen und sich widersprechen.
struct Dashboarddaten: Codable, Sendable, Hashable {
let konten: Kontostaende
let fortschritt: Budgetfortschritt
let bilanz: EinnahmenAusgaben
let letzte: LetzteBuchungen
}
extension CertainClient {
func dashboard() async throws -> Dashboarddaten {
async let konten = kontostaende()
async let fortschritt = budgetfortschritt()
async let bilanz = einnahmenAusgaben()
async let letzte = letzteBuchungen()
return try await Dashboarddaten(konten: konten, fortschritt: fortschritt,
bilanz: bilanz, letzte: letzte)
}
}
/// Die Startseite. Anders als im Web ist die Auswahl der Karten fest – ein
/// verschiebbares Raster auf Telefonbreite wäre Arbeit ohne Gewinn.
struct DashboardAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<Dashboarddaten>?
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { daten in
ScrollView {
VStack(spacing: 16) {
BilanzKarte(bilanz: daten.bilanz)
FortschrittKarte(fortschritt: daten.fortschritt)
KontenKarte(staende: daten.konten)
LetzteKarte(letzte: daten.letzte)
}
.padding()
}
.refreshable { await lader.aktualisiere() }
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "reiter.dashboard"))
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<Dashboarddaten>(schluessel: "dashboard") {
try await client.dashboard()
}
lader = neuer
await neuer.lade()
}
}
}
// MARK: - Karten
/// Der Rahmen, den alle Karten teilen.
private struct Karte<Inhalt: View>: View {
let titel: String
var fussnote: String?
@ViewBuilder let inhalt: () -> Inhalt
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(titel)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
inhalt()
if let fussnote {
Text(fussnote)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
/// Einnahmen, Ausgaben und was übrig bleibt – die Zahl, wegen der man die
/// App öffnet.
private struct BilanzKarte: View {
let bilanz: EinnahmenAusgaben
var body: some View {
Karte(titel: String(localized: "dashboard.bilanz")) {
VStack(alignment: .leading, spacing: 10) {
Text(bilanz.uebrig.formatiert())
.font(.largeTitle.weight(.semibold).monospacedDigit())
.foregroundStyle(bilanz.net < 0 ? .red : .primary)
.contentTransition(.numericText())
HStack(spacing: 20) {
Beschriftet(titel: String(localized: "dashboard.einnahmen"),
wert: bilanz.einnahmen.formatiert(), farbe: .green)
Beschriftet(titel: String(localized: "dashboard.ausgaben"),
wert: bilanz.ausgaben.formatiert(), farbe: .red)
}
}
}
}
private struct Beschriftet: View {
let titel: String
let wert: String
let farbe: Color
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(titel)
.font(.caption)
.foregroundStyle(.secondary)
Text(wert)
.font(.callout.monospacedDigit())
.foregroundStyle(farbe)
}
}
}
}
/// Je Gruppe ein Balken: wie viel vom Budget schon weg ist.
private struct FortschrittKarte: View {
let fortschritt: Budgetfortschritt
var body: some View {
Karte(titel: String(localized: "dashboard.budgetfortschritt")) {
if fortschritt.rows.isEmpty {
Text(String(localized: "dashboard.keinBudget"))
.font(.callout)
.foregroundStyle(.secondary)
} else {
VStack(spacing: 12) {
ForEach(fortschritt.rows) { zeile in
Balken(zeile: zeile, waehrung: fortschritt.currency)
}
}
}
}
}
private struct Balken: View {
let zeile: Budgetfortschritt.Zeile
let waehrung: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(zeile.name)
.font(.callout)
.lineLimit(1)
Spacer(minLength: 8)
Text(Geldbetrag(cents: zeile.actual, waehrung: waehrung).formatiert())
.font(.caption.monospacedDigit())
Text("/")
.font(.caption)
.foregroundStyle(.tertiary)
Text(Geldbetrag(cents: zeile.planned, waehrung: waehrung).formatiert())
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
ProgressView(value: zeile.anteil)
.tint(farbe)
}
.accessibilityElement(children: .combine)
}
/// Rot nur bei echter Überschreitung. Wer mehr zurücklegt als
/// geplant, hat nichts überzogen – deshalb fragt `good` dazwischen.
private var farbe: Color {
if zeile.good { return .green }
return zeile.over ? .red : Color(hex: zeile.color)
}
}
}
/// Die Kontostände, und was davon wirklich verfügbar ist.
private struct KontenKarte: View {
let staende: Kontostaende
var body: some View {
Karte(titel: String(localized: "dashboard.kontostaende")) {
VStack(spacing: 10) {
ForEach(staende.accounts.filter { !$0.archived }) { konto in
HStack(spacing: 10) {
Symbolplakette(symbol: konto.icon, farbe: konto.color, groesse: 28)
Text(konto.name)
.font(.callout)
.lineLimit(1)
if konto.locked {
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityLabel(String(localized: "konten.gebunden"))
}
Spacer(minLength: 8)
Text(konto.saldo.formatiert())
.font(.callout.monospacedDigit())
.foregroundStyle(konto.balanceCents < 0 ? .red : .primary)
}
}
Divider()
HStack {
Text(String(localized: "konten.verfuegbar"))
.font(.callout.weight(.medium))
Spacer()
Text(staende.summe.formatiert())
.font(.callout.weight(.medium).monospacedDigit())
}
if staende.gebunden != 0 {
HStack {
Text(String(localized: "konten.gebundenSumme"))
Spacer()
Text(staende.gebundenBetrag.formatiert())
.monospacedDigit()
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
/// Was zuletzt passiert ist. Die Überschriften kommen fertig formuliert vom
/// Server – bei einem Übertrag steht dort „Konto A → Konto B".
private struct LetzteKarte: View {
let letzte: LetzteBuchungen
var body: some View {
Karte(titel: String(localized: "dashboard.letzteBuchungen")) {
if letzte.transactions.isEmpty {
Text(String(localized: "buchungen.leerErklaerung"))
.font(.callout)
.foregroundStyle(.secondary)
} else {
VStack(spacing: 12) {
ForEach(letzte.transactions) { zeile in
HStack(spacing: 10) {
Symbolplakette(symbol: zeile.icon, farbe: zeile.color, groesse: 30)
VStack(alignment: .leading, spacing: 1) {
Text(zeile.title)
.font(.callout)
.lineLimit(1)
Text(zeile.date.formatiert())
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Text(zeile.betrag.formatiert(vorzeichen: .ohne))
.font(.callout.monospacedDigit())
.foregroundStyle(farbe(zeile.vorzeichen))
}
}
}
}
}
}
private func farbe(_ vorzeichen: LetzteBuchungen.Zeile.Vorzeichen) -> Color {
switch vorzeichen {
case .plus: return .green
case .minus: return .primary
case .keines: return .secondary
}
}
}
@@ -0,0 +1,210 @@
import SwiftUI
/// Einstellungen und die Nebenräume: Kategorien, Schlagwörter, Bank,
/// zweiter Faktor.
///
/// Sie hängen hier und nicht als eigene Reiter: Man braucht sie selten, und
/// eine Leiste mit zehn Reitern findet niemand mehr.
struct EinstellungenAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var vorgang = Speichervorgang()
@State private var zeigeAbmeldefrage = false
@State private var markierte: [MarkiertesWort] = []
@State private var aenderePIN = false
var body: some View {
Form {
Section {
if case .bereit(let benutzer) = zustand.lage {
LabeledContent(String(localized: "einstellungen.angemeldetAls"),
value: benutzer.anzeigename)
}
if let adresse = zustand.serverAdresse {
LabeledContent(String(localized: "einstellungen.server"),
value: adresse.host() ?? adresse.absoluteString)
}
NavigationLink {
ZweiterFaktorAnsicht()
} label: {
Label(String(localized: "zweifaktor.titel"), systemImage: "lock.shield")
}
Button {
aenderePIN = true
} label: {
Label(String(localized: "pin.aendern"), systemImage: "lock.rotation")
}
} header: {
Text(String(localized: "einstellungen.zugang"))
} footer: {
Text(String(localized: "einstellungen.markeErklaerung"))
}
Section(String(localized: "nebenraeume.titel")) {
NavigationLink {
KategorienAnsicht()
} label: {
Label(String(localized: "nebenraeume.kategorien"), systemImage: "square.grid.2x2")
}
NavigationLink {
TagsAnsicht()
} label: {
Label(String(localized: "nebenraeume.tags"), systemImage: "tag")
}
NavigationLink {
BankAnsicht()
} label: {
Label(String(localized: "nebenraeume.bank"), systemImage: "building.columns")
}
}
if !markierte.isEmpty {
Section {
ForEach(markierte) { wort in
MarkiertesWortZeile(wort: wort,
behalten: { Task { await behalte(wort) } },
entfernen: { Task { await entferne(wort) } })
}
} header: {
Text(String(localized: "kategorien.markiertTitel"))
} footer: {
Text(String(localized: "kategorien.markiertErklaerung"))
}
}
Section {
Picker(String(localized: "einstellungen.erscheinungsbild"),
selection: bindung(\.erscheinungsbild, "theme")) {
Text(String(localized: "einstellungen.system")).tag("system")
Text(String(localized: "einstellungen.hell")).tag("light")
Text(String(localized: "einstellungen.dunkel")).tag("dark")
}
Picker(String(localized: "einstellungen.hauptwaehrung"),
selection: bindung(\.basiswaehrung, "base_currency")) {
ForEach(zustand.einstellungen.aktiveWaehrungen, id: \.self) { code in
Text(code).tag(code)
}
}
Picker(String(localized: "einstellungen.budgetStarttag"),
selection: tagBindung) {
ForEach(1...28, id: \.self) { tag in
Text("\(tag).").tag(tag)
}
}
} header: {
Text(String(localized: "einstellungen.ausDemServer"))
} footer: {
// Die Hauptwährung rechnet alle Auswertungen um. Das gilt
// für jedes Gerät, nicht nur für dieses.
Text(String(localized: "einstellungen.serverweitErklaerung"))
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
Section {
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
zeigeAbmeldefrage = true
}
} footer: {
Text(String(localized: "einstellungen.abmeldenErklaerung"))
}
}
.sheet(isPresented: $aenderePIN) {
PINEinrichtenAnsicht(pflicht: false)
}
.navigationTitle(String(localized: "reiter.einstellungen"))
.refreshable { await lade() }
.task { await lade() }
.confirmationDialog(String(localized: "einstellungen.abmeldenFrage"),
isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) {
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
Task { await zustand.meldeAb() }
}
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
}
}
// MARK: - Einstellungen schreiben
/// Eine Bindung, die beim Setzen gleich zum Server geht.
///
/// Ein „Sichern"-Knopf wäre hier falsch: Es ist je Zeile genau ein Wert,
/// und das Backend nimmt ihn einzeln entgegen (`PUT /settings/{key}`).
private func bindung(_ pfad: KeyPath<Einstellungen, String>,
_ schluessel: String) -> Binding<String> {
Binding(get: { zustand.einstellungen[keyPath: pfad] },
set: { neu in Task { await setze(schluessel, neu) } })
}
private var tagBindung: Binding<Int> {
Binding(get: { zustand.einstellungen.budgetStarttag },
set: { neu in Task { await setze("budget_start_day", String(neu)) } })
}
private func setze(_ schluessel: String, _ wert: String) async {
guard let client = zustand.client else { return }
let geglueckt = await vorgang.fuehreAus {
_ = try await client.setzeEinstellung(schluessel, wert)
}
if geglueckt { await zustand.aktualisiereEinstellungen() }
}
// MARK: - Markierte Erkennungswörter
private func lade() async {
await zustand.aktualisiereEinstellungen()
guard let client = zustand.client else { return }
markierte = (try? await client.markierteErkennungswoerter()) ?? []
}
private func behalte(_ wort: MarkiertesWort) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await client.bestaetigeErkennungswort(wort.id) }) {
await lade()
}
}
private func entferne(_ wort: MarkiertesWort) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) {
await lade()
await zustand.ladeNachschlag()
}
}
}
/// Ein Erkennungswort, das oft wieder korrigiert wird.
private struct MarkiertesWortZeile: View {
let wort: MarkiertesWort
let behalten: () -> Void
let entfernen: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(wort.word)
.font(.callout)
Spacer()
Text(wort.categoryName)
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(String(format: String(localized: "kategorien.markiertZaehler"),
wort.appliedCount, wort.overriddenCount))
.font(.caption2)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Button(String(localized: "kategorien.behalten"), action: behalten)
.buttonStyle(.bordered)
.controlSize(.small)
Button(String(localized: "kategorien.entfernen"), role: .destructive,
action: entfernen)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.vertical, 2)
}
}
+169
View File
@@ -0,0 +1,169 @@
import SwiftUI
/// Die Kontenübersicht – die erste Fachansicht und zugleich die Probe, ob
/// Netzwerk, Modelle, Zwischenspeicher und Geldformatierung zusammenpassen.
struct KontenListe: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<[Konto]>?
@State private var zeigeArchivierte = false
@State private var formular: KontoFormular.Zweck?
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { konten in
liste(konten)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "konten.titel"))
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle(String(localized: "konten.archivierteZeigen"),
isOn: $zeigeArchivierte)
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button { formular = .neu } label: { Image(systemName: "plus") }
.accessibilityLabel(String(localized: "konto.neu"))
}
}
.sheet(item: $formular) { zweck in
KontoFormular(zweck: zweck) { Task { await lader?.aktualisiere() } }
.environment(zustand)
}
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<[Konto]>(schluessel: "accounts") {
try await client.konten()
}
lader = neuer
await neuer.lade()
}
}
@ViewBuilder
private func liste(_ alle: [Konto]) -> some View {
let sichtbar = alle.filter { zeigeArchivierte || !$0.isArchived }
if sichtbar.isEmpty {
LeerAnsicht(titel: String(localized: "konten.leerTitel"),
erklaerung: String(localized: "konten.leerErklaerung"),
symbol: "wallet.bifold")
} else {
List {
Section {
ForEach(sichtbar) { konto in
Button {
formular = .aendern(konto)
} label: {
KontoZeile(konto: konto)
}
.buttonStyle(.plain)
}
} footer: {
Gesamtsumme(konten: sichtbar,
waehrung: zustand.einstellungen.basiswaehrung)
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
}
}
}
private struct KontoZeile: View {
let konto: Konto
var body: some View {
HStack(spacing: 12) {
Symbolplakette(symbol: konto.icon, farbe: konto.color, groesse: 36)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(konto.name)
.font(.body)
.lineLimit(1)
if konto.isLocked {
// „Gebundenes Geld": zählt nicht zum verfügbaren Betrag.
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityLabel(String(localized: "konten.gebunden"))
}
if konto.isArchived {
Text(String(localized: "konten.archiviert"))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
}
Text(konto.type.name)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(konto.saldo.formatiert())
.font(.body.monospacedDigit())
.foregroundStyle(konto.balanceCents < 0 ? .red : .primary)
if konto.weichtVonDerBankAb, let bank = konto.bankBalanceCents {
// Nur melden, nicht korrigieren – welcher Wert stimmt,
// entscheidet der Mensch.
Label(Geldbetrag(cents: bank, waehrung: konto.currency).formatiert(),
systemImage: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(.orange)
.accessibilityLabel(String(localized: "konten.bankWeichtAb"))
}
}
}
.padding(.vertical, 2)
}
}
/// Die Summe unten. Gebundene und archivierte Konten bleiben aussen vor –
/// genauso rechnet das Dashboard im Backend (`routers/dashboard.py`).
private struct Gesamtsumme: View {
let konten: [Konto]
let waehrung: String
var body: some View {
let verfuegbar = konten
.filter { !$0.isLocked && !$0.isArchived }
.reduce(0) { $0 + $1.balanceBaseCents }
let gebunden = konten
.filter { $0.isLocked && !$0.isArchived }
.reduce(0) { $0 + $1.balanceBaseCents }
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(String(localized: "konten.verfuegbar"))
Spacer()
Text(Geldbetrag(cents: verfuegbar, waehrung: waehrung).formatiert())
.monospacedDigit()
}
.font(.callout.weight(.medium))
.foregroundStyle(.primary)
if gebunden != 0 {
HStack {
Text(String(localized: "konten.gebundenSumme"))
Spacer()
Text(Geldbetrag(cents: gebunden, waehrung: waehrung).formatiert())
.monospacedDigit()
}
.font(.caption)
}
}
.padding(.top, 6)
}
}
+214
View File
@@ -0,0 +1,214 @@
import SwiftUI
/// Ein Konto anlegen oder ändern.
struct KontoFormular: View {
enum Zweck: Equatable, Identifiable {
case neu
case aendern(Konto)
var id: Int {
switch self {
case .neu: return 0
case .aendern(let konto): return konto.id
}
}
}
let zweck: Zweck
var fertig: () -> Void
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
@State private var vorgang = Speichervorgang()
@State private var name = ""
@State private var art: Kontoart = .girokonto
@State private var waehrung = "EUR"
@State private var anfangssaldoCents = 0
@State private var hatStichtag = false
@State private var stichtag = Date()
@State private var hatKreditrahmen = false
@State private var kreditrahmenCents = 0
@State private var istGebunden = false
@State private var istArchiviert = false
@State private var symbol = "wallet"
@State private var farbe = "#3f51b5"
@State private var fragtNachLoeschen = false
@State private var vorbelegt = false
/// Was das Backend annimmt (`_check_currency`). Mehr braucht ein
/// Haushaltsbuch hier nicht; die Hauptwährung steht in den Einstellungen.
private static let waehrungen = ["EUR", "CHF", "USD", "GBP"]
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(localized: "konto.name"), text: $name)
Picker(String(localized: "konto.art"), selection: $art) {
ForEach(Kontoart.allCases, id: \.self) { eine in
Text(eine.name).tag(eine)
}
}
if case .neu = zweck {
Picker(String(localized: "konto.waehrung"), selection: $waehrung) {
ForEach(Self.waehrungen, id: \.self) { code in
Text(code).tag(code)
}
}
} else {
// Nachträglich zu wechseln liesse jeden gespeicherten
// Betrag etwas anderes bedeuten. Deshalb nur zu sehen.
LabeledContent(String(localized: "konto.waehrung"), value: waehrung)
}
}
Section {
Betragsfeld(titel: String(localized: "konto.anfangssaldo"),
waehrung: waehrung, cents: $anfangssaldoCents)
Toggle(String(localized: "konto.stichtagKennen"), isOn: $hatStichtag)
if hatStichtag {
DatePicker(String(localized: "konto.stichtag"), selection: $stichtag,
displayedComponents: .date)
}
} footer: {
Text(String(localized: "konto.anfangssaldoErklaerung"))
}
if art == .kreditkarte {
Section {
Toggle(String(localized: "konto.kreditrahmenKennen"),
isOn: $hatKreditrahmen)
if hatKreditrahmen {
Betragsfeld(titel: String(localized: "konto.kreditrahmen"),
waehrung: waehrung, cents: $kreditrahmenCents)
}
} footer: {
Text(String(localized: "konto.kreditrahmenErklaerung"))
}
}
Section {
Toggle(String(localized: "konto.gebunden"), isOn: $istGebunden)
} footer: {
Text(String(localized: "konto.gebundenErklaerung"))
}
Section {
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
}
if case .aendern = zweck {
Section {
Toggle(String(localized: "konto.archiviert"), isOn: $istArchiviert)
Button(String(localized: "konto.loeschen"), role: .destructive) {
fragtNachLoeschen = true
}
} footer: {
Text(String(localized: "konto.archivierenStattLoeschen"))
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.sichern")) {
Task { await sichere() }
}
.disabled(!istVollstaendig || vorgang.laeuft)
}
}
.interactiveDismissDisabled(vorgang.laeuft)
.confirmationDialog(String(localized: "konto.loeschenFrage"),
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
Button(String(localized: "konto.loeschen"), role: .destructive) {
Task { await loesche() }
}
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
} message: {
Text(String(localized: "konto.loeschenErklaerung"))
}
.onAppear(perform: belegeVor)
}
}
private var titel: String {
switch zweck {
case .neu: return String(localized: "konto.neu")
case .aendern: return String(localized: "konto.aendern")
}
}
private var istVollstaendig: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func belegeVor() {
guard !vorbelegt else { return }
vorbelegt = true
guard case .aendern(let konto) = zweck else {
waehrung = zustand.einstellungen.basiswaehrung
return
}
name = konto.name
art = konto.type
waehrung = konto.currency
anfangssaldoCents = konto.openingBalanceCents
hatStichtag = konto.openingDate != nil
stichtag = konto.openingDate?.datum ?? Date()
hatKreditrahmen = konto.creditLimitCents != nil
kreditrahmenCents = konto.creditLimitCents ?? 0
istGebunden = konto.isLocked
istArchiviert = konto.isArchived
symbol = konto.icon
farbe = konto.color
}
private func sichere() async {
guard let client = zustand.client else { return }
let sauberName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let datum = hatStichtag ? Tagesdatum(stichtag) : nil
// Nur bei Kreditkarten überhaupt ein Rahmen – bei allem anderen wäre
// die Zahl bedeutungslos und würde beim Umstellen der Art hängenbleiben.
let rahmen = (art == .kreditkarte && hatKreditrahmen) ? kreditrahmenCents : nil
let geglueckt = await vorgang.fuehreAus {
switch zweck {
case .neu:
_ = try await client.legeKontoAn(KontoAnlegen(
name: sauberName, type: art, currency: waehrung, icon: symbol,
color: farbe, openingBalanceCents: anfangssaldoCents,
openingDate: datum, creditLimitCents: rahmen, isLocked: istGebunden))
case .aendern(let konto):
_ = try await client.aendereKonto(konto.id, KontoAendern(
name: sauberName, type: art, icon: symbol, color: farbe,
openingBalanceCents: anfangssaldoCents, openingDate: datum,
creditLimitCents: rahmen, isLocked: istGebunden,
isArchived: istArchiviert))
}
}
if geglueckt {
await zustand.ladeNachschlag()
fertig()
schliesse()
}
}
private func loesche() async {
guard let client = zustand.client, case .aendern(let konto) = zweck else { return }
if await vorgang.fuehreAus({ try await client.loescheKonto(konto.id) }) {
await zustand.ladeNachschlag()
fertig()
schliesse()
}
}
}
+3
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+99
View File
@@ -0,0 +1,99 @@
import Foundation
/// `SubscriptionListOut` – die Vertragsliste samt Kopfzahlen.
struct Aboliste: Codable, Sendable, Hashable {
let subscriptions: [Abo]
let summary: Abosummen
}
/// `SubscriptionOut` aus `backend/app/schemas.py`.
struct Abo: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let icon: String
let color: String
/// Der Betrag je Zahlung, in der Währung des Vertrags.
let amountCents: Int
let currency: String
/// 1 = monatlich, 12 = jährlich, 3 = vierteljährlich …
let intervalMonths: Int
let accountId: Int?
let categoryId: Int?
let nextDue: Tagesdatum?
/// Woran die Erkennung den Vertrag in einer Buchung wiedererkennt.
let payeePattern: String?
let savingsAccountId: Int?
let isActive: Bool
let cancelledAt: Tagesdatum?
let notes: String?
let position: Int
/// Auf einen Monat umgelegt, in der Vertragswährung – vom Server gerechnet.
let monatlichCents: Int
/// Dasselbe in der Hauptwährung. Fehlt der Kurs, fällt es still auf den
/// nativen Wert zurück; welche Währungen betroffen sind, sagt
/// `Abosummen.ohneKurs`.
let monatlichBaseCents: Int
let tageBisFaellig: Int?
let hasLogo: Bool
let logoVersion: Int?
private enum CodingKeys: String, CodingKey {
case id, name, icon, color, currency, notes, position
case amountCents = "amount_cents"
case intervalMonths = "interval_months"
case accountId = "account_id"
case categoryId = "category_id"
case nextDue = "next_due"
case payeePattern = "payee_pattern"
case savingsAccountId = "savings_account_id"
case isActive = "is_active"
case cancelledAt = "cancelled_at"
case monatlichCents = "monatlich_cents"
case monatlichBaseCents = "monatlich_base_cents"
case tageBisFaellig = "tage_bis_faellig"
case hasLogo = "has_logo"
case logoVersion = "logo_version"
}
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
/// „monatlich", „jährlich", „alle 3 Monate".
var rhythmus: String {
switch intervalMonths {
case 1: return String(localized: "abo.monatlich")
case 3: return String(localized: "abo.vierteljaehrlich")
case 6: return String(localized: "abo.halbjaehrlich")
case 12: return String(localized: "abo.jaehrlich")
default: return String(format: String(localized: "abo.alleNMonate"), intervalMonths)
}
}
/// Steht die nächste Zahlung in der nächsten Woche an?
var istBaldFaellig: Bool {
guard isActive, let tage = tageBisFaellig else { return false }
return tage <= 7
}
}
/// Was am Kopf der Liste steht – die Zahl, wegen der man sie öffnet.
struct Abosummen: Codable, Sendable, Hashable {
let currency: String
let monatlichCents: Int
let jaehrlichCents: Int
let anzahlAktiv: Int
/// Währungen, die sich mangels Kurs nicht umrechnen liessen. Lieber
/// nennen als stillschweigend zu klein summieren.
let ohneKurs: [String]
private enum CodingKeys: String, CodingKey {
case currency
case monatlichCents = "monatlich_cents"
case jaehrlichCents = "jaehrlich_cents"
case anzahlAktiv = "anzahl_aktiv"
case ohneKurs = "ohne_kurs"
}
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
var jaehrlich: Geldbetrag { Geldbetrag(cents: jaehrlichCents, waehrung: currency) }
}
+215
View File
@@ -0,0 +1,215 @@
import Foundation
/// `InboxItemOut` – eine von aussen erfasste Zahlung, bevor der Kontoauszug
/// sie bestätigt.
///
/// Quellen: `shortcut` (Kurzbefehl vom Telefon), `mail` (Postfach-Abruf),
/// `bank`. Der Eintrag bleibt auch nach dem Buchen stehen, damit der Abgleich
/// dieselbe Zahlung später wiedererkennt.
struct Eingangseintrag: Codable, Sendable, Identifiable, Hashable {
let id: Int
let source: String
let status: Eingangsstand
let accountId: Int?
let date: Tagesdatum
/// Negativ bei einer Ausgabe – anders als an einer Buchung, wo die
/// Richtung in `type` steckt.
let amountCents: Int
let currency: String
let payee: String?
let note: String?
let categoryId: Int?
let transactionId: Int?
/// Das jeweils **andere** Konto, wenn die Buchung ein Übertrag ist. Ohne
/// das liesse sich ein Übertrag nicht von einer Ausgabe unterscheiden,
/// und die App fragte weiter nach einer Kategorie.
let transferAccountId: Int?
let fxRate: Double?
let counterAmountCents: Int?
let counterCurrency: String?
let createdAt: Zeitpunkt?
private enum CodingKeys: String, CodingKey {
case id, source, status, date, currency, payee, note
case accountId = "account_id"
case amountCents = "amount_cents"
case categoryId = "category_id"
case transactionId = "transaction_id"
case transferAccountId = "transfer_account_id"
case fxRate = "fx_rate"
case counterAmountCents = "counter_amount_cents"
case counterCurrency = "counter_currency"
case createdAt = "created_at"
}
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
var istUebertrag: Bool { transferAccountId != nil }
/// Buchen geht nur mit Konto – das Backend lehnt es sonst mit 400 ab.
var laesstSichBuchen: Bool {
accountId != nil && (status == .pending || status == .unclear)
}
/// Fehlt nach dem Buchen noch die Kategorie? Überträge brauchen keine.
var brauchtKategorie: Bool {
status == .booked && categoryId == nil && !istUebertrag
}
var quelle: String {
switch source {
case "shortcut": return String(localized: "eingang.quelle.kurzbefehl")
case "mail": return String(localized: "eingang.quelle.postfach")
case "bank": return String(localized: "eingang.quelle.bank")
default: return source
}
}
}
/// `InboxStatus`
enum Eingangsstand: String, Codable, Sendable {
/// Als Buchung übernommen.
case booked
/// Wartet auf Bestätigung.
case pending
/// Die Erkennung war unsicher – erst ansehen.
case unclear
/// Verworfen. Taucht in der Liste nicht mehr auf.
case discarded
var name: String {
switch self {
case .booked: return String(localized: "eingang.stand.gebucht")
case .pending: return String(localized: "eingang.stand.offen")
case .unclear: return String(localized: "eingang.stand.unklar")
case .discarded: return String(localized: "eingang.stand.verworfen")
}
}
}
/// `GET /api/uebertraege/vorschlaege`
///
/// Zwei getrennte Buchungen, die zusammen ein Übertrag sein könnten. Das
/// Backend entscheidet hier **nichts** – beim Hinsehen darf sich nichts ändern.
struct Uebertragsvorschlaege: Codable, Sendable, Hashable {
/// Wie viele beim Suchen von selbst verknüpft wurden. Beim blossen
/// Fragen immer null.
let verknuepft: Int
let vorschlaege: [Vorschlag]
struct Vorschlag: Codable, Sendable, Identifiable, Hashable {
let a: Seite
let b: Seite
/// Die Reihenfolge ist nicht bedeutsam – die ID ist es deshalb auch
/// nur als Paar.
var id: String { "\(min(a.id, b.id))-\(max(a.id, b.id))" }
/// Eine zugeordnete Buchung fällt durchs Verbinden aus dem Budget.
/// Das soll niemanden überraschen.
var hatKategorie: Bool { a.categoryId != nil || b.categoryId != nil }
}
struct Seite: Codable, Sendable, Identifiable, Hashable {
let id: Int
let date: Tagesdatum
let type: Buchungsart
let amountCents: Int
let currency: String
let accountId: Int
let accountName: String
let payee: String?
let description: String?
let categoryId: Int?
private enum CodingKeys: String, CodingKey {
case id, date, type, currency, payee, description
case amountCents = "amount_cents"
case accountId = "account_id"
case accountName = "account_name"
case categoryId = "category_id"
}
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
var bezeichnung: String {
for text in [payee, description] where !(text ?? "").isEmpty { return text! }
return String(localized: "buchung.ohneBezeichnung")
}
}
}
/// `GET /api/transactions/dubletten`
///
/// Buchungen, die gleich aussehen. 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 Mensch.
struct Dublettengruppen: Codable, Sendable, Hashable {
let gruppen: [Gruppe]
struct Gruppe: Codable, Sendable, Identifiable, Hashable {
let accountId: Int
let datum: Tagesdatum
let amountCents: Int
let typ: Buchungsart
let zeilen: [Zeile]
var id: String { "\(accountId)-\(datum.text)-\(amountCents)-\(typ.rawValue)" }
private enum CodingKeys: String, CodingKey {
case datum, typ, zeilen
case accountId = "account_id"
case amountCents = "amount_cents"
}
struct Zeile: Codable, Sendable, Identifiable, Hashable {
let id: Int
let payee: String?
let description: String?
let categoryId: Int?
let herkunft: String
/// Die Kennung der Bank. Tragen zwei Zeilen verschiedene, hat die
/// Bank sie ausdrücklich als verschiedene Zahlungen gemeldet.
let kennung: String?
private enum CodingKeys: String, CodingKey {
case id, payee, description, herkunft, kennung
case categoryId = "category_id"
}
var bezeichnung: String {
for text in [payee, description] where !(text ?? "").isEmpty { return text! }
return String(localized: "buchung.ohneBezeichnung")
}
}
}
}
/// Zwei Buchungen, die zusammengehören – für Verbinden und Ablehnen.
struct Buchungspaar: Encodable, Sendable {
let aId: Int
let bId: Int
private enum CodingKeys: String, CodingKey {
case aId = "a_id"
case bId = "b_id"
}
}
/// `POST /api/inbox/{id}/transfer`
struct AlsUebertragBuchen: Encodable, Sendable {
let toAccountId: Int
/// Nur setzen, wenn der Nutzer es nach einer Rückfrage ausdrücklich will.
/// Das Backend antwortet mit 409, wenn es Bedenken hat.
var trotzdem = false
private enum CodingKeys: String, CodingKey {
case trotzdem
case toAccountId = "to_account_id"
}
}
/// Was `POST /api/inbox/book-all` zurückmeldet.
struct SammelErgebnis: Codable, Sendable, Hashable {
let gebucht: Int
let uebertraege: Int?
}
+192
View File
@@ -0,0 +1,192 @@
import Foundation
/// `TransactionOut` aus `backend/app/schemas.py`.
///
/// Drei Beträge an jeder Buchung, und keiner davon wird hier gerechnet:
/// `amountCents` steht in der Kontowährung, `amountEurCents` ist der beim
/// Speichern eingefrorene EUR-Wert, `amountBaseCents` rechnet der Server bei
/// jedem Lesen in die Hauptwährung um. Die App formatiert nur.
struct Buchung: Codable, Sendable, Identifiable, Hashable {
let id: Int
let type: Buchungsart
let date: Tagesdatum
/// `"14:18:15"` oder nichts. Nur zur Anzeige – gerechnet wird mit dem Tag.
let time: String?
let accountId: Int
let toAccountId: Int?
let accountCurrency: String
let amountCents: Int
let toAmountCents: Int?
let fxRate: Double?
let amountEurCents: Int
let amountBaseCents: Int
let categoryId: Int?
let budgetTreatment: Budgetwirkung?
let budgetCategoryId: Int?
/// Der Vertrag, zu dem die Buchung gehört – von der Erkennung gesetzt.
let subscriptionId: Int?
let budgetPeriod: String
let budgetPeriodManuell: Bool
let importBatchId: Int?
/// bank, mail, shortcut, transfer, csv, manuell.
let herkunft: String
let payee: String?
let description: String?
let notes: String?
let tagIds: [Int]
private enum CodingKeys: String, CodingKey {
case id, type, date, time, herkunft, payee, description, notes
case accountId = "account_id"
case toAccountId = "to_account_id"
case accountCurrency = "account_currency"
case amountCents = "amount_cents"
case toAmountCents = "to_amount_cents"
case fxRate = "fx_rate"
case amountEurCents = "amount_eur_cents"
case amountBaseCents = "amount_base_cents"
case categoryId = "category_id"
case budgetTreatment = "budget_treatment"
case budgetCategoryId = "budget_category_id"
case subscriptionId = "subscription_id"
case budgetPeriod = "budget_period"
case budgetPeriodManuell = "budget_period_manuell"
case importBatchId = "import_batch_id"
case tagIds = "tag_ids"
}
/// In der Kontowährung.
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: accountCurrency) }
/// Was in der Liste als Überschrift steht. Ohne Empfänger nimmt der
/// Verwendungszweck seinen Platz ein – eine leere Zeile hilft niemandem.
var ueberschrift: String {
for text in [payee, description] where !(text ?? "").isEmpty {
return text!
}
return String(localized: "buchung.ohneBezeichnung")
}
/// Die Zeile darunter: Verwendungszweck, falls er nicht schon oben steht.
var unterschrift: String? {
guard let beschreibung = description, !beschreibung.isEmpty,
beschreibung != ueberschrift else { return nil }
return beschreibung
}
/// Ob diese Buchung Geld aus dem Haushalt herausbewegt. Überträge nicht –
/// sie verschieben nur.
var istAusgabe: Bool { type == .expense }
}
/// `TxnType`
enum Buchungsart: String, Codable, Sendable, CaseIterable, Identifiable {
case expense, income, transfer
var id: String { rawValue }
var name: String {
switch self {
case .expense: return String(localized: "buchungsart.ausgabe")
case .income: return String(localized: "buchungsart.einnahme")
case .transfer: return String(localized: "buchungsart.uebertrag")
}
}
var symbol: String {
switch self {
case .expense: return "arrow.up.right"
case .income: return "arrow.down.left"
case .transfer: return "arrow.left.arrow.right"
}
}
}
/// `BudgetTreatment` – ob eine Buchung im Budget zählt oder bloss durchläuft.
enum Budgetwirkung: String, Codable, Sendable {
case budgeted, neutral
}
/// Ein Eintrag aus `GET /api/transactions/links`: die Gegenseite einer
/// Buchung, die mit einer zweiten zu einem Übertrag verknüpft ist.
///
/// Das Gegenkonto steht mit drin, weil die andere Buchung oft gar nicht in
/// der gerade gefilterten Liste steht – und „Übertrag" ohne Angabe wohin
/// sagt nichts.
struct Uebertragsverknuepfung: Codable, Sendable, Hashable {
let linkId: Int
let otherId: Int
/// `"raus"`: Von dieser Buchung geht das Geld weg. `"rein"`: hier kommt es an.
let richtung: String
let otherAccountId: Int?
let automatisch: Bool
private enum CodingKeys: String, CodingKey {
case richtung, automatisch
case linkId = "link_id"
case otherId = "other_id"
case otherAccountId = "other_account_id"
}
var gehtRaus: Bool { richtung == "raus" }
}
/// Eine Zeile der Buchungsliste: entweder eine einzelne Buchung oder zwei,
/// die zusammen einen Übertrag ergeben.
///
/// Das Backend kennt zwei Mechanismen nebeneinander: einen Übertrag am Stück
/// (eine Buchung mit `toAccountId`) und die Verknüpfung zweier getrennter
/// Buchungen, wie die Bank sie liefert. Beide Beine als zwei Zeilen zu zeigen,
/// hiesse dasselbe Geld zweimal zeigen. Das Zusammenfassen macht der Client –
/// das Backend liefert nur die Paarliste.
struct Buchungszeile: Identifiable, Hashable, Sendable {
let buchung: Buchung
/// Das zweite Bein, wenn es in derselben Liste steht.
let gegenstueck: Buchung?
/// Die Verknüpfung selbst – auch dann da, wenn das zweite Bein fehlt.
let verknuepfung: Uebertragsverknuepfung?
var id: Int { buchung.id }
var istUebertrag: Bool { buchung.type == .transfer || verknuepfung != nil }
/// Von welchem Konto das Geld weggeht.
var vonKonto: Int {
guard let verknuepfung else { return buchung.accountId }
if verknuepfung.gehtRaus { return buchung.accountId }
return gegenstueck?.accountId ?? verknuepfung.otherAccountId ?? buchung.accountId
}
/// Auf welchem es ankommt – `nil`, wenn es sich nicht sagen lässt.
var nachKonto: Int? {
guard let verknuepfung else { return buchung.toAccountId }
if verknuepfung.gehtRaus { return gegenstueck?.accountId ?? verknuepfung.otherAccountId }
return buchung.accountId
}
/// Baut aus einer Buchungsliste und der Paarliste die Anzeigezeilen.
///
/// Zusammengefasst wird nur, wenn **beide** Beine in dieser Liste stehen.
/// Fehlt eines, bleibt die Zeile für sich – sie trägt dann aber die
/// Verknüpfung, damit sie „Übertrag nach …" sagen kann statt gar nichts.
/// Ohne diese Vorsicht liesse ein Kontofilter Zeilen verschwinden, die
/// sehr wohl zu diesem Konto gehören.
static func zeilen(aus buchungen: [Buchung],
paare: [String: Uebertragsverknuepfung]) -> [Buchungszeile] {
let nachId = Dictionary(buchungen.map { ($0.id, $0) }, uniquingKeysWith: { erstes, _ in erstes })
var erledigt = Set<Int>()
var ergebnis: [Buchungszeile] = []
for buchung in buchungen where !erledigt.contains(buchung.id) {
let verknuepfung = paare[String(buchung.id)]
let anderes = verknuepfung.flatMap { nachId[$0.otherId] }
if let anderes {
erledigt.insert(anderes.id)
}
erledigt.insert(buchung.id)
ergebnis.append(Buchungszeile(buchung: buchung, gegenstueck: anderes,
verknuepfung: verknuepfung))
}
return ergebnis
}
}
+175
View File
@@ -0,0 +1,175 @@
import Foundation
/// Die Monatsansicht aus `GET /api/budget/{label}` beziehungsweise
/// `/budget/current` (`_build_view` in `backend/app/routers/budgets.py`).
///
/// Alle Beträge stehen schon in der Hauptwährung. Der Client rechnet nur,
/// was es in der API nicht gibt: Prozente und „Rest".
struct Budgetmonat: Codable, Sendable, Hashable {
/// `"2026-10"`.
let label: String
let start: Tagesdatum
let end: Tagesdatum
/// Ob dieser Monat von der Vorlage abweicht.
let customized: Bool
let baseCurrency: String
let prev: String
let next: String
let groups: [Budgetgruppe]
let income: [GeplanteEinnahme]
let vertraege: Vertragsreste
let totals: Budgetsummen
private enum CodingKeys: String, CodingKey {
case label, start, end, customized, prev, next, groups, income, vertraege, totals
case baseCurrency = "base_currency"
}
/// Was vom geplanten Ausgabenbudget noch übrig ist. Kann negativ werden.
var restCents: Int { totals.plannedExpense - totals.actualExpense }
/// Anteil des Verbrauchten, zwischen 0 und 1 gekappt – für einen Balken.
/// Ohne Plan gibt es keinen Anteil, nicht etwa einen vollen Balken.
var anteilVerbraucht: Double {
guard totals.plannedExpense > 0 else { return 0 }
return min(1, max(0, Double(totals.actualExpense) / Double(totals.plannedExpense)))
}
}
/// Eine Kategoriegruppe im Budget.
struct Budgetgruppe: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let icon: String
let color: String
let plannedCents: Int
let actualCents: Int
/// Überzogen. Sparen ist davon ausgenommen – siehe `good`.
let over: Bool
/// Alle Kategorien darin sind „Ausgaben, die kein Verlust sind" (Sparen).
/// Dann ist Mehr-als-geplant keine Warnung.
let good: Bool
let ausVertraegenCents: Int
let categories: [Budgetkategorie]
private enum CodingKeys: String, CodingKey {
case id, name, icon, color, over, good, categories
case plannedCents = "planned_cents"
case actualCents = "actual_cents"
case ausVertraegenCents = "aus_vertraegen_cents"
}
}
/// Eine Zeile im Budget.
struct Budgetkategorie: Codable, Sendable, Identifiable, Hashable {
let categoryId: Int
let name: String
let icon: String
let color: String
let plannedCents: Int
let actualCents: Int
let deltaCents: Int
let over: Bool
/// `"fest"` oder `"berechnet"`. Bei „berechnet" ergibt sich der Soll-Wert
/// aus den Verträgen plus Zuschlag.
let modus: String
let zuschlagCents: Int
/// Was die Verträge dieser Kategorie im Monat kosten. Steht auch bei
/// „fest" da – dann als Hinweis, nicht als Summand.
let ausVertraegenCents: Int
let vertraegeAnzahl: Int
let good: Bool
var id: Int { categoryId }
private enum CodingKeys: String, CodingKey {
case name, icon, color, over, modus, good
case categoryId = "category_id"
case plannedCents = "planned_cents"
case actualCents = "actual_cents"
case deltaCents = "delta_cents"
case zuschlagCents = "zuschlag_cents"
case ausVertraegenCents = "aus_vertraegen_cents"
case vertraegeAnzahl = "vertraege_anzahl"
}
var istBerechnet: Bool { modus == "berechnet" }
var anteilVerbraucht: Double {
guard plannedCents > 0 else { return 0 }
return min(1, max(0, Double(actualCents) / Double(plannedCents)))
}
}
/// Eine geplante Einnahme – „Gehalt", „Kindergeld".
struct GeplanteEinnahme: Codable, Sendable, Identifiable, Hashable {
let id: Int
let label: String
let categoryId: Int?
let targetAccountId: Int?
let plannedCents: Int
let receivedCents: Int
private enum CodingKeys: String, CodingKey {
case id, label
case categoryId = "category_id"
case targetAccountId = "target_account_id"
case plannedCents = "planned_cents"
case receivedCents = "received_cents"
}
}
/// Verträge, die sich nicht zuordnen liessen.
///
/// Ohne diese Angaben fehlt ihr Geld im Budget, ohne dass irgendwo steht,
/// dass es fehlt. `ohneKurs` nennt die Währungen, für die kein Wechselkurs
/// vorlag – deren Beträge fehlen in der Summe.
struct Vertragsreste: Codable, Sendable, Hashable {
let ohneKategorieCents: Int
let ohneKategorieAnzahl: Int
let ohneKurs: [String]
private enum CodingKeys: String, CodingKey {
case ohneKategorieCents = "ohne_kategorie_cents"
case ohneKategorieAnzahl = "ohne_kategorie_anzahl"
case ohneKurs = "ohne_kurs"
}
var istLeer: Bool { ohneKategorieAnzahl == 0 && ohneKurs.isEmpty }
}
struct Budgetsummen: Codable, Sendable, Hashable {
let plannedExpense: Int
let actualExpense: Int
let plannedIncome: Int
let receivedIncome: Int
private enum CodingKeys: String, CodingKey {
case plannedExpense = "planned_expense"
case actualExpense = "actual_expense"
case plannedIncome = "planned_income"
case receivedIncome = "received_income"
}
}
/// `GET /api/budget/template` – das Standard-Budget hinter den Monaten.
struct Budgetvorlage: Codable, Sendable, Hashable {
let lines: [Vorlagenzeile]
let income: [GeplanteEinnahme]
}
struct Vorlagenzeile: Codable, Sendable, Identifiable, Hashable {
let categoryId: Int
let plannedCents: Int
let modus: String
let zuschlagCents: Int
var id: Int { categoryId }
private enum CodingKeys: String, CodingKey {
case modus
case categoryId = "category_id"
case plannedCents = "planned_cents"
case zuschlagCents = "zuschlag_cents"
}
}
+123
View File
@@ -0,0 +1,123 @@
import Foundation
// Die Karten des Dashboards. Anders als im Web ist die Auswahl hier fest –
// ein verschiebbares Raster auf Telefonbreite wäre Arbeit ohne Gewinn. Die
// vier Endpunkte sind dieselben, die das Web benutzt.
/// `GET /api/dashboard/balances`
struct Kontostaende: Codable, Sendable, Hashable {
let accounts: [Kontozeile]
/// Verfügbar, ohne gebundene und archivierte Konten.
let total: Int
/// Was auf gebundenen Konten liegt – Geld, an das man nicht herankommt.
let gebunden: Int
let currency: String
var summe: Geldbetrag { Geldbetrag(cents: total, waehrung: currency) }
var gebundenBetrag: Geldbetrag { Geldbetrag(cents: gebunden, waehrung: currency) }
struct Kontozeile: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let icon: String
let color: String
let currency: String
let balanceCents: Int
let balanceBaseCents: Int
let archived: Bool
let locked: Bool
private enum CodingKeys: String, CodingKey {
case id, name, icon, color, currency, archived, locked
case balanceCents = "balance_cents"
case balanceBaseCents = "balance_base_cents"
}
var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) }
}
}
/// `GET /api/dashboard/budget-progress`
struct Budgetfortschritt: Codable, Sendable, Hashable {
let rows: [Zeile]
let currency: String
let label: String
let groupBy: String
private enum CodingKeys: String, CodingKey {
case rows, currency, label
case groupBy = "group_by"
}
struct Zeile: Codable, Sendable, Identifiable, Hashable {
let name: String
let color: String
let planned: Int
let actual: Int
let over: Bool
/// Sparen: mehr als geplant ist hier keine Warnung.
let good: Bool
var id: String { name }
var anteil: Double {
guard planned > 0 else { return 0 }
return min(1, max(0, Double(actual) / Double(planned)))
}
}
}
/// `GET /api/dashboard/income-expense`
struct EinnahmenAusgaben: Codable, Sendable, Hashable {
let income: Int
let expense: Int
let net: Int
let currency: String
let label: String
var einnahmen: Geldbetrag { Geldbetrag(cents: income, waehrung: currency) }
var ausgaben: Geldbetrag { Geldbetrag(cents: expense, waehrung: currency) }
var uebrig: Geldbetrag { Geldbetrag(cents: net, waehrung: currency) }
}
/// `GET /api/dashboard/recent`
struct LetzteBuchungen: Codable, Sendable, Hashable {
let transactions: [Zeile]
let currency: String
struct Zeile: Codable, Sendable, Identifiable, Hashable {
let id: Int
let date: Tagesdatum
let type: Buchungsart
/// Schon fertig formuliert: die Kategorie, sonst der Empfänger, bei
/// einem Übertrag „Konto A → Konto B".
let title: String
let icon: String
let color: String
/// `"pos"`, `"neg"` oder nichts – bei einem Übertrag gibt es keine
/// Richtung, weil kein Geld den Haushalt verlässt.
let sign: String?
let amountCents: Int
let currency: String
let amountBaseCents: Int
private enum CodingKeys: String, CodingKey {
case id, date, type, title, icon, color, sign, currency
case amountCents = "amount_cents"
case amountBaseCents = "amount_base_cents"
}
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
/// Grün bei Einnahme, rot bei Ausgabe, neutral beim Übertrag.
var vorzeichen: Vorzeichen {
switch sign {
case "pos": return .plus
case "neg": return .minus
default: return .keines
}
}
enum Vorzeichen: Sendable { case plus, minus, keines }
}
}
+186
View File
@@ -0,0 +1,186 @@
import Foundation
// Was die App zum Server schickt. Getrennt von den Lesemodellen, weil das
// Backend es ebenso trennt: `TransactionCreate` kennt Felder, die
// `TransactionOut` nicht hat, und umgekehrt (`subscription_id` etwa steht nur
// in der Ausgabe – beim Anlegen gibt man es nicht mit).
//
// Alle Änderungstypen sind **teilweise**: Nur was gesetzt ist, geht mit. Ein
// `nil` darf nicht als `null` hinausgehen, sonst löschte jedes Speichern die
// Felder, die das Formular gar nicht anfasst.
//
// Das macht Swift von selbst – die erzeugte `encode(to:)` ruft für Optionals
// `encodeIfPresent`. Hier stand das eine Weile von Hand ausgeschrieben, bis
// ein Test zeigte, dass es dasselbe tut. Der Test bleibt: Die Eigenschaft ist
// zu wichtig, um sie ungeprüft einer Spracheigenheit zu überlassen.
/// `TransactionCreate`
struct BuchungAnlegen: Encodable, Sendable {
var type: Buchungsart
var date: Tagesdatum
var accountId: Int
var toAccountId: Int?
/// Immer positiv – die Richtung steckt in `type`, nicht im Vorzeichen.
var amountCents: Int
var toAmountCents: Int?
var categoryId: Int?
var payee: String?
var description: String?
var notes: String?
var tagIds: [Int] = []
private enum CodingKeys: String, CodingKey {
case type, date, payee, description, notes
case accountId = "account_id"
case toAccountId = "to_account_id"
case amountCents = "amount_cents"
case toAmountCents = "to_amount_cents"
case categoryId = "category_id"
case tagIds = "tag_ids"
}
}
/// `TransactionUpdate` – nur die gesetzten Felder gehen mit.
struct BuchungAendern: Encodable, Sendable {
var type: Buchungsart?
var date: Tagesdatum?
var accountId: Int?
var toAccountId: Int?
var amountCents: Int?
var categoryId: Int?
var payee: String?
var description: String?
var notes: String?
/// Mitgeschickt heisst **ersetzen**; eine leere Liste nimmt alle Tags ab.
/// Weggelassen lässt sie unberührt.
var tagIds: [Int]?
private enum CodingKeys: String, CodingKey {
case type, date, payee, description, notes
case accountId = "account_id"
case toAccountId = "to_account_id"
case amountCents = "amount_cents"
case categoryId = "category_id"
case tagIds = "tag_ids"
}
}
/// `AccountCreate`
struct KontoAnlegen: Encodable, Sendable {
var name: String
var type: Kontoart = .girokonto
var currency: String = "EUR"
var icon: String = "wallet"
var color: String = "#3f51b5"
var openingBalanceCents: Int = 0
var openingDate: Tagesdatum?
var creditLimitCents: Int?
var isLocked: Bool = false
private enum CodingKeys: String, CodingKey {
case name, type, currency, icon, color
case openingBalanceCents = "opening_balance_cents"
case openingDate = "opening_date"
case creditLimitCents = "credit_limit_cents"
case isLocked = "is_locked"
}
}
/// `AccountUpdate` – nur die gesetzten Felder gehen mit.
///
/// Die Währung fehlt mit Absicht: Sie nachträglich zu wechseln liesse jeden
/// gespeicherten Betrag etwas anderes bedeuten. Wer das will, legt ein neues
/// Konto an.
struct KontoAendern: Encodable, Sendable {
var name: String?
var type: Kontoart?
var icon: String?
var color: String?
var openingBalanceCents: Int?
var openingDate: Tagesdatum?
var creditLimitCents: Int?
var isLocked: Bool?
var isArchived: Bool?
private enum CodingKeys: String, CodingKey {
case name, type, icon, color
case openingBalanceCents = "opening_balance_cents"
case openingDate = "opening_date"
case creditLimitCents = "credit_limit_cents"
case isLocked = "is_locked"
case isArchived = "is_archived"
}
}
/// `SubscriptionIn`
struct AboAnlegen: Encodable, Sendable {
var name: String
var icon: String = "refresh-ccw"
var color: String = "#6b7480"
var amountCents: Int
var currency: String = "EUR"
/// Nur 1, 2, 3, 6 oder 12 – das Backend weist alles andere ab.
var intervalMonths: Int = 1
var categoryId: Int?
var accountId: Int?
var nextDue: Tagesdatum?
var payeePattern: String?
var notes: String?
private enum CodingKeys: String, CodingKey {
case name, icon, color, currency, notes
case amountCents = "amount_cents"
case intervalMonths = "interval_months"
case categoryId = "category_id"
case accountId = "account_id"
case nextDue = "next_due"
case payeePattern = "payee_pattern"
}
}
/// `SubscriptionUpdate` – nur die gesetzten Felder gehen mit.
struct AboAendern: Encodable, Sendable {
var name: String?
var icon: String?
var color: String?
var amountCents: Int?
var intervalMonths: Int?
var categoryId: Int?
var accountId: Int?
var nextDue: Tagesdatum?
var payeePattern: String?
var isActive: Bool?
var notes: String?
private enum CodingKeys: String, CodingKey {
case name, icon, color, notes
case amountCents = "amount_cents"
case intervalMonths = "interval_months"
case categoryId = "category_id"
case accountId = "account_id"
case nextDue = "next_due"
case payeePattern = "payee_pattern"
case isActive = "is_active"
}
}
/// Die möglichen Zahlungsrhythmen. Mehr nimmt das Backend nicht an.
enum Zahlungsrhythmus: Int, CaseIterable, Identifiable, Sendable {
case monatlich = 1
case zweimonatlich = 2
case vierteljaehrlich = 3
case halbjaehrlich = 6
case jaehrlich = 12
var id: Int { rawValue }
var name: String {
switch self {
case .monatlich: return String(localized: "abo.monatlich")
case .zweimonatlich: return String(format: String(localized: "abo.alleNMonate"), 2)
case .vierteljaehrlich: return String(localized: "abo.vierteljaehrlich")
case .halbjaehrlich: return String(localized: "abo.halbjaehrlich")
case .jaehrlich: return String(localized: "abo.jaehrlich")
}
}
}
+145
View File
@@ -0,0 +1,145 @@
import Foundation
// Die Typen hier bilden die Antwortschemas aus `backend/app/schemas.py` nach.
// Feldnamen stimmen eins zu eins überein – deshalb braucht keiner von ihnen
// `CodingKeys`. Wer ein Feld umbenennt, bricht genau hier.
/// `GET /api/version` – die Prüfung, ob unter einer Adresse ein Certain läuft.
struct Serverkennung: Codable, Sendable {
let name: String
let version: String
let gitSha: String?
private enum CodingKeys: String, CodingKey {
case name, version
case gitSha = "git_sha"
}
/// Der Name, den ein echter Server trägt (`backend/app/branding.py`).
var istCertain: Bool { name.localizedCaseInsensitiveContains("certain") }
}
/// `UserOut`
struct Benutzer: Codable, Sendable, Identifiable, Hashable {
let id: Int
let username: String
let role: Rolle
let displayName: String?
let isActive: Bool
let mustChangePassword: Bool
let mustChangePasswordReason: String?
let totpEnabled: Bool
let createdAt: Zeitpunkt
let mfaSetupRequired: Bool
private enum CodingKeys: String, CodingKey {
case id, username, role
case displayName = "display_name"
case isActive = "is_active"
case mustChangePassword = "must_change_password"
case mustChangePasswordReason = "must_change_password_reason"
case totpEnabled = "totp_enabled"
case createdAt = "created_at"
case mfaSetupRequired = "mfa_setup_required"
}
enum Rolle: String, Codable, Sendable {
case admin, user
}
var anzeigename: String { displayName?.isEmpty == false ? displayName! : username }
}
/// `LoginOut` – `status` ist `"ok"` oder `"2fa_required"`.
struct AnmeldeAntwort: Codable, Sendable {
let status: String
let user: Benutzer?
var brauchtZweitenFaktor: Bool { status == "2fa_required" }
}
/// `TwoFactorVerify`
struct ZweiterFaktorEingabe: Codable, Sendable {
let code: String
let recovery: Bool
}
/// `PasswordChange`
struct PasswortWechsel: Codable, Sendable {
let currentPassword: String
let newPassword: String
private enum CodingKeys: String, CodingKey {
case currentPassword = "current_password"
case newPassword = "new_password"
}
}
/// `ApiTokenCreate`
struct MarkeAnlegen: Codable, Sendable {
let name: String
let scope: String
let defaultAccountId: Int?
private enum CodingKeys: String, CodingKey {
case name, scope
case defaultAccountId = "default_account_id"
}
/// Die Marke, mit der sich die App anmeldet.
static func fuerApp(name: String) -> MarkeAnlegen {
MarkeAnlegen(name: name, scope: "app", defaultAccountId: nil)
}
}
/// `ApiTokenOut` beziehungsweise `ApiTokenCreated`.
/// `token` trägt den Klartext – und zwar genau einmal, beim Anlegen.
struct Marke: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let scope: String
let defaultAccountId: Int?
let lastUsedAt: Zeitpunkt?
let createdAt: Zeitpunkt?
let token: String?
private enum CodingKeys: String, CodingKey {
case id, name, scope, token
case defaultAccountId = "default_account_id"
case lastUsedAt = "last_used_at"
case createdAt = "created_at"
}
}
/// Ein Eintrag aus `GET /api/settings`. Werte sind immer Zeichenketten.
struct Einstellung: Codable, Sendable, Hashable {
let key: String
let value: String
}
/// Die Einstellungen als benutzbares Ganzes.
struct Einstellungen: Sendable, Hashable {
var basiswaehrung: String = "EUR"
var budgetStarttag: Int = 1
var sprache: String = "de"
var erscheinungsbild: String = "system"
var datumsformat: String = "de"
var aktiveWaehrungen: [String] = ["EUR", "CHF", "USD"]
init() {}
init(_ eintraege: [Einstellung]) {
var nach: [String: String] = [:]
for eintrag in eintraege { nach[eintrag.key] = eintrag.value }
basiswaehrung = nach["base_currency"] ?? basiswaehrung
budgetStarttag = min(31, max(1, Int(nach["budget_start_day"] ?? "") ?? 1))
sprache = nach["locale"] ?? sprache
erscheinungsbild = nach["theme"] ?? erscheinungsbild
datumsformat = nach["date_format"] ?? datumsformat
if let roh = nach["active_currencies"] {
aktiveWaehrungen = roh.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces).uppercased() }
.filter { !$0.isEmpty }
}
}
}
+124
View File
@@ -0,0 +1,124 @@
import Foundation
/// `CategoryGroupOut` – Kategorien stehen immer in einer Gruppe.
struct Kategoriegruppe: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let kind: Kategorieart
let icon: String
let color: String
let position: Int
let isBuiltin: Bool
let categories: [Kategorie]
private enum CodingKeys: String, CodingKey {
case id, name, kind, icon, color, position, categories
case isBuiltin = "is_builtin"
}
}
/// `CategoryOut`
struct Kategorie: Codable, Sendable, Identifiable, Hashable {
let id: Int
let groupId: Int
let name: String
let type: Kategorieart
let icon: String
let color: String
let position: Int
let isBuiltin: Bool
let isArchived: Bool
/// Zählt in keiner Auswertung mit – etwa „Durchlaufende Posten".
let excludedFromStats: Bool
/// Eine Ausgabe, die kein Verlust ist: Sparen. Mehr als geplant ist hier
/// keine Warnung.
let spendingIsGood: Bool
let keywordCount: Int
private enum CodingKeys: String, CodingKey {
case id, name, type, icon, color, position
case groupId = "group_id"
case isBuiltin = "is_builtin"
case isArchived = "is_archived"
case excludedFromStats = "excluded_from_stats"
case spendingIsGood = "spending_is_good"
case keywordCount = "keyword_count"
}
}
/// `CategoryKind`
enum Kategorieart: String, Codable, Sendable {
case income, expense
}
/// `TagOut` – ein frei vergebenes Etikett, quer zur Kategorie.
struct Schlagwort: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let color: String
}
/// Kategorien und Tags, wie die Ansichten sie brauchen: einmal geladen,
/// danach als Nachschlagewerk.
///
/// Fast jede Liste will zu einer ID den Namen, das Symbol und die Farbe.
/// Das je Zeile zu holen wäre genau das N+1, das das Backend sich selbst
/// verbietet.
struct Nachschlag: Codable, Sendable {
let gruppen: [Kategoriegruppe]
let schlagwoerter: [Schlagwort]
let konten: [Konto]
private let kategorienNachId: [Int: Kategorie]
private let schlagwortNachId: [Int: Schlagwort]
private let kontoNachId: [Int: Konto]
init(gruppen: [Kategoriegruppe], schlagwoerter: [Schlagwort], konten: [Konto]) {
self.gruppen = gruppen
self.schlagwoerter = schlagwoerter
self.konten = konten
let alle = gruppen.flatMap(\.categories)
self.kategorienNachId = Dictionary(alle.map { ($0.id, $0) },
uniquingKeysWith: { erstes, _ in erstes })
self.schlagwortNachId = Dictionary(schlagwoerter.map { ($0.id, $0) },
uniquingKeysWith: { erstes, _ in erstes })
self.kontoNachId = Dictionary(konten.map { ($0.id, $0) },
uniquingKeysWith: { erstes, _ in erstes })
}
static let leer = Nachschlag(gruppen: [], schlagwoerter: [], konten: [])
/// Gespeichert werden nur die drei Listen; die Nachschlagetabellen baut
/// der Konstruktor daraus neu. Sie mitzuschreiben hiesse, dieselben Daten
/// zweimal auf der Platte zu halten.
private enum CodingKeys: String, CodingKey {
case gruppen, schlagwoerter, konten
}
init(from decoder: Decoder) throws {
let behaelter = try decoder.container(keyedBy: CodingKeys.self)
self.init(gruppen: try behaelter.decode([Kategoriegruppe].self, forKey: .gruppen),
schlagwoerter: try behaelter.decode([Schlagwort].self, forKey: .schlagwoerter),
konten: try behaelter.decode([Konto].self, forKey: .konten))
}
func encode(to encoder: Encoder) throws {
var behaelter = encoder.container(keyedBy: CodingKeys.self)
try behaelter.encode(gruppen, forKey: .gruppen)
try behaelter.encode(schlagwoerter, forKey: .schlagwoerter)
try behaelter.encode(konten, forKey: .konten)
}
/// Alle Ausgabekategorien, flach – für Auswahllisten.
var ausgabekategorien: [Kategorie] {
gruppen.filter { $0.kind == .expense }.flatMap(\.categories).filter { !$0.isArchived }
}
func kategorie(_ id: Int?) -> Kategorie? { id.flatMap { kategorienNachId[$0] } }
func schlagwort(_ id: Int) -> Schlagwort? { schlagwortNachId[id] }
func konto(_ id: Int?) -> Konto? { id.flatMap { kontoNachId[$0] } }
func kontoname(_ id: Int?) -> String {
konto(id)?.name ?? String(localized: "allgemein.unbekannt")
}
}
+87
View File
@@ -0,0 +1,87 @@
import Foundation
/// `AccountOut` aus `backend/app/schemas.py`.
struct Konto: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let type: Kontoart
let currency: String
let icon: String
let color: String
let openingBalanceCents: Int
let openingDate: Tagesdatum?
let creditLimitCents: Int?
let isLocked: Bool
let isArchived: Bool
let position: Int
let savingsCategoryId: Int?
let identifiers: [Kontokennung]
/// Saldo in der Währung des Kontos.
let balanceCents: Int
/// Derselbe Saldo in der Hauptwährung – vom Server gerechnet.
let balanceBaseCents: Int
/// Was die Bank selbst meldet. Nur bei angebundenen Konten gesetzt.
let bankBalanceCents: Int?
let bankBalanceAt: Zeitpunkt?
/// Nur ein gebuchter Banksaldo lässt sich mit unserer Rechnung
/// vergleichen; ein verfügbarer enthält Vorgemerktes.
let bankBalanceVergleichbar: Bool
private enum CodingKeys: String, CodingKey {
case id, name, type, currency, icon, color, position, identifiers
case openingBalanceCents = "opening_balance_cents"
case openingDate = "opening_date"
case creditLimitCents = "credit_limit_cents"
case isLocked = "is_locked"
case isArchived = "is_archived"
case savingsCategoryId = "savings_category_id"
case balanceCents = "balance_cents"
case balanceBaseCents = "balance_base_cents"
case bankBalanceCents = "bank_balance_cents"
case bankBalanceAt = "bank_balance_at"
case bankBalanceVergleichbar = "bank_balance_vergleichbar"
}
var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) }
/// Ob der gemeldete Banksaldo von unserer Rechnung abweicht – nur dann ist
/// ein Hinweis angebracht.
var weichtVonDerBankAb: Bool {
guard bankBalanceVergleichbar, let bank = bankBalanceCents else { return false }
return bank != balanceCents
}
}
/// `AccountType`
enum Kontoart: String, Codable, Sendable, CaseIterable {
case girokonto, sparkonto, bargeld, kreditkarte, depot, sonstiges
var name: String {
switch self {
case .girokonto: return String(localized: "kontoart.girokonto")
case .sparkonto: return String(localized: "kontoart.sparkonto")
case .bargeld: return String(localized: "kontoart.bargeld")
case .kreditkarte: return String(localized: "kontoart.kreditkarte")
case .depot: return String(localized: "kontoart.depot")
case .sonstiges: return String(localized: "kontoart.sonstiges")
}
}
/// Ein Übertrag auf diese Konten zählt im Budget als Sparen, nicht als
/// neutrale Umbuchung (`SAVINGS_ACCOUNT_TYPES` im Backend).
var giltAlsSparen: Bool { self == .sparkonto || self == .depot }
}
/// `AccountIdentifierOut`
struct Kontokennung: Codable, Sendable, Identifiable, Hashable {
let id: Int
let kind: Art
let value: String
enum Art: String, Codable, Sendable {
case iban
case accountNo = "account_no"
case cardNo = "card_no"
case text
}
}
+218
View File
@@ -0,0 +1,218 @@
import Foundation
// MARK: - Schlagwörter
/// `KeywordOut` – ein Wort, an dem die Erkennung eine Kategorie festmacht.
///
/// Nicht zu verwechseln mit `Schlagwort` (einem Tag): Ein Tag klebt an einer
/// Buchung, ein Schlagwort hängt an einer **Kategorie** und wirkt nur auf
/// künftige Importe.
struct Erkennungswort: Codable, Sendable, Identifiable, Hashable {
let id: Int
let categoryId: Int
let word: String
/// Nur wenn der Betrag mitentscheiden soll – „Migros" über 200 ist
/// Haushalt, darunter Lebensmittel.
let amountCents: Int?
private enum CodingKeys: String, CodingKey {
case id, word
case categoryId = "category_id"
case amountCents = "amount_cents"
}
}
/// `FlaggedKeywordOut` – ein Wort, das oft wieder korrigiert wird.
///
/// Ab fünf Anwendungen und mindestens einem Drittel Korrekturen meldet das
/// Backend es zur Prüfung. Behalten oder entfernen entscheidet der Mensch.
struct MarkiertesWort: Codable, Sendable, Identifiable, Hashable {
let id: Int
let categoryId: Int?
let categoryName: String
let word: String
let appliedCount: Int
let overriddenCount: Int
private enum CodingKeys: String, CodingKey {
case id, word
case categoryId = "category_id"
case categoryName = "category_name"
case appliedCount = "applied_count"
case overriddenCount = "overridden_count"
}
}
/// `KeywordCreate`
struct ErkennungswortAnlegen: Encodable, Sendable {
let word: String
var amountCents: Int?
private enum CodingKeys: String, CodingKey {
case word
case amountCents = "amount_cents"
}
}
// MARK: - Kategorien schreiben
/// `CategoryCreate`
struct KategorieAnlegen: Encodable, Sendable {
let groupId: Int
let name: String
var icon = "more-horizontal"
var color = "#6b7480"
/// Zählt in keiner Auswertung mit – etwa durchlaufende Posten.
var excludedFromStats = false
/// Eine Ausgabe, die kein Verlust ist: Sparen.
var spendingIsGood = false
private enum CodingKeys: String, CodingKey {
case name, icon, color
case groupId = "group_id"
case excludedFromStats = "excluded_from_stats"
case spendingIsGood = "spending_is_good"
}
}
/// `CategoryUpdate` – nur die gesetzten Felder gehen mit.
struct KategorieAendern: Encodable, Sendable {
var name: String?
var groupId: Int?
var icon: String?
var color: String?
var isArchived: Bool?
var excludedFromStats: Bool?
var spendingIsGood: Bool?
private enum CodingKeys: String, CodingKey {
case name, icon, color
case groupId = "group_id"
case isArchived = "is_archived"
case excludedFromStats = "excluded_from_stats"
case spendingIsGood = "spending_is_good"
}
}
/// `CategoryGroupCreate`
struct GruppeAnlegen: Encodable, Sendable {
let name: String
var kind: Kategorieart = .expense
var icon = "list"
var color = "#6b7480"
}
/// `CategoryGroupUpdate`
struct GruppeAendern: Encodable, Sendable {
var name: String?
var icon: String?
var color: String?
}
// MARK: - Tags schreiben
/// `TagCreate`
struct TagAnlegen: Encodable, Sendable {
let name: String
var color = "#6b7480"
}
/// `TagUpdate`
struct TagAendern: Encodable, Sendable {
var name: String?
var color: String?
}
// MARK: - Zweiter Faktor
/// `TwoFactorStatusOut`
struct ZweiterFaktorStand: Codable, Sendable, Hashable {
let enabled: Bool
let recoveryCodesLeft: Int
private enum CodingKeys: String, CodingKey {
case enabled
case recoveryCodesLeft = "recovery_codes_left"
}
}
/// `TwoFactorSetupOut` – das noch **nicht** aktive Geheimnis.
///
/// Gültig wird es erst, wenn ein Code daraus geprüft wurde. Wer die
/// Einrichtung abbricht, lässt einfach ein unbenutztes Geheimnis stehen.
struct ZweiterFaktorEinrichtung: Codable, Sendable, Hashable {
let secret: String
let otpauthUri: String
/// Der QR-Code als `data:`-URI. Auf dem Telefon selten gebraucht – dort
/// führt der Weg über `otpauthUri` direkt in die Authenticator-App.
let qrPng: String
private enum CodingKeys: String, CodingKey {
case secret
case otpauthUri = "otpauth_uri"
case qrPng = "qr_png"
}
}
/// `RecoveryCodesOut` – einmal zu sehen, danach nie wieder.
struct Rettungscodes: Codable, Sendable, Hashable {
let codes: [String]
}
struct ZweiterFaktorCode: Encodable, Sendable {
let code: String
}
// MARK: - Bank
/// `BankConnectionOut` – eine bestehende Anbindung.
///
/// Hergestellt wird sie am Rechner: Die Bank leitet fest auf
/// `{base_url}/bank/rueckkehr` zurück. Abrufen geht von hier.
struct Bankverbindung: Codable, Sendable, Identifiable, Hashable {
let id: Int
let aspspName: String
let aspspCountry: String
let validUntil: Zeitpunkt?
let lastSyncAt: Zeitpunkt?
/// Takt des automatischen Abrufs in Minuten, 0 = aus.
let pollMinutes: Int
let lastError: String?
let expiresSoon: Bool
private enum CodingKeys: String, CodingKey {
case id
case aspspName = "aspsp_name"
case aspspCountry = "aspsp_country"
case validUntil = "valid_until"
case lastSyncAt = "last_sync_at"
case pollMinutes = "poll_minutes"
case lastError = "last_error"
case expiresSoon = "expires_soon"
}
}
/// Was ein Abruf zurückmeldet.
struct Abrufergebnis: Codable, Sendable, Hashable {
let geholt: Int
let neu: Int
let gebucht: Int?
let vorgemerkt: Int?
let doppelt: Int?
let uebertraege: Int?
/// Die Verbindung hat kein zugeordnetes Konto – dann wurde nichts geholt.
let ohneZuordnung: Bool?
private enum CodingKeys: String, CodingKey {
case geholt, neu, gebucht, vorgemerkt, doppelt, uebertraege
case ohneZuordnung = "ohne_zuordnung"
}
/// Ein Satz für die Rückmeldung.
var meldung: String {
if ohneZuordnung == true {
return String(localized: "bank.ohneZuordnung")
}
return String(format: String(localized: "bank.abrufErgebnis"), geholt, neu)
}
}
+127
View File
@@ -0,0 +1,127 @@
import Foundation
/// Ein Kalendertag ohne Uhrzeit, so wie das Backend ihn schickt: `"2026-07-14"`.
///
/// Warum ein eigener Typ und nicht `Date` mit einer Decoder-Strategie: Das
/// Backend mischt zwei Formate. Buchungsdaten sind reine Tage, `created_at`
/// und Konsorten sind volle Zeitstempel mit Zone. Eine einzige
/// `dateDecodingStrategy` müsste beides raten und würde mal das eine, mal das
/// andere zerlegen. Zwei saubere Typen sind hier weniger Arbeit als ein
/// cleverer Decoder.
struct Tagesdatum: Codable, Hashable, Comparable, Sendable, CustomStringConvertible {
/// Mitternacht dieses Tages, in der Zeitzone des Geräts.
let datum: Date
/// Die Form, in der es kam und wieder hinausgeht.
let text: String
private static let leser: DateFormatter = {
let f = DateFormatter()
f.calendar = Calendar(identifier: .gregorian)
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = .current
f.dateFormat = "yyyy-MM-dd"
return f
}()
init?(_ text: String) {
guard let geparst = Tagesdatum.leser.date(from: text) else { return nil }
self.datum = geparst
self.text = text
}
init(_ datum: Date) {
self.datum = datum
self.text = Tagesdatum.leser.string(from: datum)
}
static var heute: Tagesdatum { Tagesdatum(Date()) }
init(from decoder: Decoder) throws {
let roh = try decoder.singleValueContainer().decode(String.self)
guard let fertig = Tagesdatum(roh) else {
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Kein Tagesdatum: \(roh)"))
}
self = fertig
}
func encode(to encoder: Encoder) throws {
var behaelter = encoder.singleValueContainer()
try behaelter.encode(text)
}
static func < (links: Tagesdatum, rechts: Tagesdatum) -> Bool {
links.datum < rechts.datum
}
var description: String { text }
/// Für die Anzeige, in der Sprache des Geräts.
func formatiert(_ stil: Date.FormatStyle.DateStyle = .abbreviated) -> String {
datum.formatted(.dateTime.day().month(.defaultDigits).year())
.replacingOccurrences(of: "\u{202F}", with: " ")
}
}
/// Ein voller Zeitstempel, wie ihn `created_at`, `last_used_at` und
/// `bank_balance_at` tragen.
///
/// Das Backend schickt ISO 8601, mal mit Sekundenbruchteilen, mal ohne, mal
/// mit `Z`, mal mit Offset. Deshalb zwei Leser hintereinander statt einem.
struct Zeitpunkt: Codable, Hashable, Comparable, Sendable {
let datum: Date
private static let mitBruchteilen: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let ohneBruchteile: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
/// Letzter Ausweg: Python schreibt ohne Zone, wenn keine gesetzt ist.
private static let ohneZone: DateFormatter = {
let f = DateFormatter()
f.calendar = Calendar(identifier: .gregorian)
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
return f
}()
init(_ datum: Date) { self.datum = datum }
init?(_ text: String) {
let ohneMikro = text.replacingOccurrences(
of: #"(\.\d{3})\d+"#, with: "$1", options: .regularExpression)
if let d = Zeitpunkt.mitBruchteilen.date(from: ohneMikro) { self.datum = d; return }
if let d = Zeitpunkt.ohneBruchteile.date(from: text) { self.datum = d; return }
let gekuerzt = String(text.prefix(19))
if let d = Zeitpunkt.ohneZone.date(from: gekuerzt) { self.datum = d; return }
return nil
}
init(from decoder: Decoder) throws {
let roh = try decoder.singleValueContainer().decode(String.self)
guard let fertig = Zeitpunkt(roh) else {
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Kein Zeitpunkt: \(roh)"))
}
self = fertig
}
func encode(to encoder: Encoder) throws {
var behaelter = encoder.singleValueContainer()
try behaelter.encode(Zeitpunkt.mitBruchteilen.string(from: datum))
}
static func < (links: Zeitpunkt, rechts: Zeitpunkt) -> Bool {
links.datum < rechts.datum
}
}
+146
View File
@@ -0,0 +1,146 @@
import SwiftUI
/// Die Bankanbindungen – abrufen, Salden auffrischen.
///
/// **Herstellen geht hier nicht.** Die Bank leitet fest auf
/// `{base_url}/bank/rueckkehr` zurück, registriert bei Enable Banking; dieser
/// Weg führt zwangsläufig über den Browser am Rechner. Abrufen dagegen ist
/// genau das, was man unterwegs will.
struct BankAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<[Bankverbindung]>?
@State private var vorgang = Speichervorgang()
@State private var meldung: String?
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { liste in
inhalt(liste)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "nebenraeume.bank"))
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<[Bankverbindung]>(schluessel: "bank/connections") {
try await client.bankverbindungen()
}
lader = neuer
await neuer.lade()
}
.alert(String(localized: "bank.abrufFertig"),
isPresented: .constant(meldung != nil)) {
Button(String(localized: "allgemein.ok")) { meldung = nil }
} message: {
Text(meldung ?? "")
}
.alert(String(localized: "fehler.titel"),
isPresented: .constant(vorgang.fehler != nil)) {
Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil }
} message: {
Text(vorgang.fehler ?? "")
}
}
@ViewBuilder
private func inhalt(_ liste: [Bankverbindung]) -> some View {
if liste.isEmpty {
LeerAnsicht(titel: String(localized: "bank.leerTitel"),
erklaerung: String(localized: "bank.leerErklaerung"),
symbol: "building.columns")
} else {
List {
ForEach(liste) { verbindung in
Section {
VerbindungsZeile(verbindung: verbindung)
Button {
Task { await rufeAb(verbindung) }
} label: {
Label(String(localized: "bank.abrufen"),
systemImage: "arrow.down.circle")
}
Button {
Task { await frischeSalden(verbindung) }
} label: {
Label(String(localized: "bank.saldenAuffrischen"),
systemImage: "arrow.clockwise")
}
} header: {
Text(verbindung.aspspName)
} footer: {
if let fehler = verbindung.lastError {
// Der letzte Fehler steht am Server. Ihn zu
// verschweigen hiesse, den Nutzer vergeblich
// abrufen zu lassen.
Label(fehler, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
}
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
.disabled(vorgang.laeuft)
}
}
private func rufeAb(_ verbindung: Bankverbindung) async {
guard let client = zustand.client else { return }
var ergebnis: Abrufergebnis?
let geglueckt = await vorgang.fuehreAus {
ergebnis = try await client.rufeBankAb(verbindung.id)
}
if geglueckt {
meldung = ergebnis?.meldung
await lader?.aktualisiere()
}
}
private func frischeSalden(_ verbindung: Bankverbindung) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await client.frischeSaldenAuf(verbindung.id) }) {
meldung = String(localized: "bank.saldenAufgefrischt")
await lader?.aktualisiere()
}
}
}
private struct VerbindungsZeile: View {
let verbindung: Bankverbindung
var body: some View {
VStack(alignment: .leading, spacing: 4) {
LabeledContent(String(localized: "bank.land"), value: verbindung.aspspCountry)
if let zuletzt = verbindung.lastSyncAt {
LabeledContent(String(localized: "bank.zuletzt")) {
Text(zuletzt.datum.formatted(.relative(presentation: .named)))
}
} else {
LabeledContent(String(localized: "bank.zuletzt"),
value: String(localized: "bank.nie"))
}
if let gueltig = verbindung.validUntil {
LabeledContent(String(localized: "bank.gueltigBis")) {
Text(gueltig.datum.formatted(.dateTime.day().month().year()))
// Eine auslaufende Anbindung bricht sonst ohne
// Vorwarnung ab, und niemand weiss warum.
.foregroundStyle(verbindung.expiresSoon ? .orange : .secondary)
}
}
if verbindung.pollMinutes > 0 {
LabeledContent(String(localized: "bank.takt")) {
Text(String(format: String(localized: "bank.alleNMinuten"),
verbindung.pollMinutes))
}
}
}
.font(.callout)
}
}
+307
View File
@@ -0,0 +1,307 @@
import SwiftUI
/// Eine Kategorie anlegen oder ändern.
struct KategorieFormular: View {
enum Zweck: Equatable, Identifiable {
/// Neu in dieser Gruppe. Die Art erbt sie von der Gruppe – eine
/// Einnahme-Kategorie in einer Ausgabe-Gruppe gibt es nicht.
case neu(Int, Kategorieart)
case aendern(Kategorie)
var id: Int {
switch self {
case .neu(let gruppe, _): return -gruppe
case .aendern(let kategorie): return kategorie.id
}
}
}
let zweck: Zweck
var fertig: () -> Void
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
@State private var vorgang = Speichervorgang()
@State private var name = ""
@State private var symbol = "more-horizontal"
@State private var farbe = Farbwahl.standard
@State private var gruppeId = 0
@State private var istSparen = false
@State private var ohneAuswertung = false
@State private var istArchiviert = false
@State private var fragtNachLoeschen = false
@State private var vorbelegt = false
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(localized: "konto.name"), text: $name)
Picker(String(localized: "kategorien.gruppe"), selection: $gruppeId) {
ForEach(passendeGruppen) { gruppe in
Text(gruppe.name).tag(gruppe.id)
}
}
}
Section {
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
}
if art == .expense {
Section {
Toggle(String(localized: "kategorien.istSparen"), isOn: $istSparen)
} footer: {
Text(String(localized: "kategorien.istSparenErklaerung"))
}
}
Section {
Toggle(String(localized: "kategorien.ohneAuswertungSchalter"),
isOn: $ohneAuswertung)
} footer: {
Text(String(localized: "kategorien.ohneAuswertungErklaerung"))
}
if case .aendern = zweck {
Section {
Toggle(String(localized: "konto.archiviert"), isOn: $istArchiviert)
Button(String(localized: "kategorien.loeschen"), role: .destructive) {
fragtNachLoeschen = true
}
} footer: {
Text(String(localized: "kategorien.archivierenStattLoeschen"))
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|| vorgang.laeuft)
}
}
.interactiveDismissDisabled(vorgang.laeuft)
.confirmationDialog(String(localized: "kategorien.loeschenFrage"),
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
Button(String(localized: "kategorien.loeschen"), role: .destructive) {
Task { await loesche() }
}
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
} message: {
Text(String(localized: "kategorien.loeschenErklaerung"))
}
.onAppear(perform: belegeVor)
}
}
private var titel: String {
switch zweck {
case .neu: return String(localized: "kategorien.neu")
case .aendern: return String(localized: "kategorien.aendern")
}
}
/// Welche Art diese Kategorie hat – sie steckt in der Gruppe, nicht in
/// der Kategorie selbst.
private var art: Kategorieart {
switch zweck {
case .neu(_, let kind): return kind
case .aendern(let kategorie): return kategorie.type
}
}
/// Umhängen nur innerhalb derselben Art: Eine Ausgabe-Kategorie in einer
/// Einnahme-Gruppe wäre ein Widerspruch.
private var passendeGruppen: [Kategoriegruppe] {
zustand.nachschlag.gruppen.filter { $0.kind == art }
}
private func belegeVor() {
guard !vorbelegt else { return }
vorbelegt = true
switch zweck {
case .neu(let gruppe, _):
gruppeId = gruppe
case .aendern(let kategorie):
name = kategorie.name
symbol = kategorie.icon
farbe = kategorie.color
gruppeId = kategorie.groupId
istSparen = kategorie.spendingIsGood
ohneAuswertung = kategorie.excludedFromStats
istArchiviert = kategorie.isArchived
}
}
private func sichere() async {
guard let client = zustand.client else { return }
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
// Sparen gibt es nur bei Ausgaben. Beim Wechsel der Art bliebe der
// Haken sonst gesetzt und wirkte still weiter.
let sparen = art == .expense && istSparen
let geglueckt = await vorgang.fuehreAus {
switch zweck {
case .neu:
_ = try await client.legeKategorieAn(KategorieAnlegen(
groupId: gruppeId, name: sauber, icon: symbol, color: farbe,
excludedFromStats: ohneAuswertung, spendingIsGood: sparen))
case .aendern(let kategorie):
_ = try await client.aendereKategorie(kategorie.id, KategorieAendern(
name: sauber, groupId: gruppeId, icon: symbol, color: farbe,
isArchived: istArchiviert, excludedFromStats: ohneAuswertung,
spendingIsGood: sparen))
}
}
if geglueckt {
fertig()
schliesse()
}
}
private func loesche() async {
guard let client = zustand.client, case .aendern(let kategorie) = zweck else { return }
if await vorgang.fuehreAus({ try await client.loescheKategorie(kategorie.id) }) {
fertig()
schliesse()
}
}
}
/// Eine Kategoriegruppe anlegen oder ändern.
struct GruppenFormular: View {
enum Zweck: Equatable, Identifiable {
case neu
case aendern(Kategoriegruppe)
var id: Int {
switch self {
case .neu: return 0
case .aendern(let gruppe): return gruppe.id
}
}
}
let zweck: Zweck
var fertig: () -> Void
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
@State private var vorgang = Speichervorgang()
@State private var name = ""
@State private var art: Kategorieart = .expense
@State private var symbol = "list"
@State private var farbe = Farbwahl.standard
@State private var fragtNachLoeschen = false
@State private var vorbelegt = false
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(localized: "konto.name"), text: $name)
if case .neu = zweck {
Picker(String(localized: "kategorien.art"), selection: $art) {
Text(String(localized: "kategorien.ausgaben")).tag(Kategorieart.expense)
Text(String(localized: "kategorien.einnahmen")).tag(Kategorieart.income)
}
.pickerStyle(.segmented)
} else {
// Die Art einer Gruppe nachträglich zu wechseln würde
// alle ihre Kategorien die Seite wechseln lassen.
LabeledContent(String(localized: "kategorien.art"),
value: art == .income
? String(localized: "kategorien.einnahmen")
: String(localized: "kategorien.ausgaben"))
}
}
Section {
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
}
if case .aendern = zweck {
Section {
Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) {
fragtNachLoeschen = true
}
} footer: {
Text(String(localized: "kategorien.gruppeLoeschenErklaerung"))
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.navigationTitle(String(localized: "kategorien.gruppe"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|| vorgang.laeuft)
}
}
.confirmationDialog(String(localized: "kategorien.gruppeLoeschenFrage"),
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) {
Task { await loesche() }
}
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
}
.onAppear {
guard !vorbelegt else { return }
vorbelegt = true
if case .aendern(let gruppe) = zweck {
name = gruppe.name
art = gruppe.kind
symbol = gruppe.icon
farbe = gruppe.color
}
}
}
}
private func sichere() async {
guard let client = zustand.client else { return }
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
let geglueckt = await vorgang.fuehreAus {
switch zweck {
case .neu:
_ = try await client.legeGruppeAn(GruppeAnlegen(
name: sauber, kind: art, icon: symbol, color: farbe))
case .aendern(let gruppe):
_ = try await client.aendereGruppe(gruppe.id, GruppeAendern(
name: sauber, icon: symbol, color: farbe))
}
}
if geglueckt {
fertig()
schliesse()
}
}
private func loesche() async {
guard let client = zustand.client, case .aendern(let gruppe) = zweck else { return }
if await vorgang.fuehreAus({ try await client.loescheGruppe(gruppe.id) }) {
fertig()
schliesse()
}
}
}
+250
View File
@@ -0,0 +1,250 @@
import SwiftUI
/// Kategorien, nach Gruppen. Antippen öffnet die Kategorie samt ihren
/// Erkennungswörtern.
struct KategorienAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var vorgang = Speichervorgang()
@State private var bearbeitet: KategorieFormular.Zweck?
@State private var gruppeBearbeiten: GruppenFormular.Zweck?
@State private var zeigeArchivierte = false
var body: some View {
List {
if zustand.nachschlag.gruppen.isEmpty {
Section {
Text(String(localized: "kategorien.leer"))
.foregroundStyle(.secondary)
}
}
ForEach(zustand.nachschlag.gruppen) { gruppe in
Section {
ForEach(sichtbar(gruppe)) { kategorie in
NavigationLink {
KategorieAnsicht(kategorie: kategorie)
} label: {
KategorieZeile(kategorie: kategorie)
}
.swipeActions(edge: .trailing) {
Button {
bearbeitet = .aendern(kategorie)
} label: {
Label(String(localized: "allgemein.bearbeiten"),
systemImage: "pencil")
}
.tint(.marke)
}
}
Button {
bearbeitet = .neu(gruppe.id, gruppe.kind)
} label: {
Label(String(localized: "kategorien.neu"), systemImage: "plus")
.font(.callout)
}
} header: {
HStack(spacing: 8) {
Image(systemName: Symbole.sf(gruppe.icon))
.foregroundStyle(Color(hex: gruppe.color))
Text(gruppe.name)
Text(gruppe.kind == .income
? String(localized: "kategorien.einnahmen")
: String(localized: "kategorien.ausgaben"))
.foregroundStyle(.tertiary)
Spacer()
Button {
gruppeBearbeiten = .aendern(gruppe)
} label: {
Image(systemName: "pencil")
}
}
.textCase(nil)
.font(.footnote)
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(String(localized: "nebenraeume.kategorien"))
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle(String(localized: "kategorien.archivierteZeigen"),
isOn: $zeigeArchivierte)
Button {
gruppeBearbeiten = .neu
} label: {
Label(String(localized: "kategorien.neueGruppe"),
systemImage: "folder.badge.plus")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.refreshable { await zustand.ladeNachschlag() }
.sheet(item: $bearbeitet) { zweck in
KategorieFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } }
.environment(zustand)
}
.sheet(item: $gruppeBearbeiten) { zweck in
GruppenFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } }
.environment(zustand)
}
}
private func sichtbar(_ gruppe: Kategoriegruppe) -> [Kategorie] {
gruppe.categories.filter { zeigeArchivierte || !$0.isArchived }
}
}
private struct KategorieZeile: View {
let kategorie: Kategorie
var body: some View {
HStack(spacing: 10) {
Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color, groesse: 30)
Text(kategorie.name)
.lineLimit(1)
Spacer(minLength: 6)
ForEach(merkmale, id: \.self) { merkmal in
Text(merkmal)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
if kategorie.keywordCount > 0 {
Label("\(kategorie.keywordCount)", systemImage: "text.magnifyingglass")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
/// Nur was vom Normalfall abweicht. Eine Plakette an jeder Zeile sagt
/// nichts mehr.
private var merkmale: [String] {
var liste: [String] = []
if kategorie.isArchived { liste.append(String(localized: "konten.archiviert")) }
if kategorie.spendingIsGood { liste.append(String(localized: "kategorien.sparen")) }
if kategorie.excludedFromStats {
liste.append(String(localized: "kategorien.ohneAuswertung"))
}
return liste
}
}
// MARK: - Eine Kategorie mit ihren Erkennungswörtern
/// Was die Erkennung an dieser Kategorie festmacht.
///
/// Erkennungswörter wirken **nur auf künftige Importe** – an bestehenden
/// Buchungen ändert sich nichts. Das steht auch so unter der Liste, sonst
/// wartet jemand vergeblich darauf, dass sich etwas rührt.
struct KategorieAnsicht: View {
let kategorie: Kategorie
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<[Erkennungswort]>?
@State private var vorgang = Speichervorgang()
@State private var neuesWort = ""
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { woerter in
liste(woerter)
}
} else {
ProgressView()
}
}
.navigationTitle(kategorie.name)
.navigationBarTitleDisplayMode(.inline)
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<[Erkennungswort]>(schluessel: "keywords/\(kategorie.id)") {
try await client.erkennungswoerter(kategorie: kategorie.id)
}
lader = neuer
await neuer.lade()
}
}
@ViewBuilder
private func liste(_ woerter: [Erkennungswort]) -> some View {
List {
Section {
ForEach(woerter) { wort in
HStack {
Text(wort.word)
Spacer()
if let cents = wort.amountCents {
Text(Geldbetrag(cents: cents,
waehrung: zustand.einstellungen.basiswaehrung)
.formatiert())
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.swipeActions {
Button(role: .destructive) {
Task { await loesche(wort) }
} label: {
Label(String(localized: "allgemein.loeschen"), systemImage: "trash")
}
}
}
HStack {
TextField(String(localized: "kategorien.wortHinzufuegen"), text: $neuesWort)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit { Task { await fuegeHinzu() } }
Button {
Task { await fuegeHinzu() }
} label: {
Image(systemName: "plus.circle.fill")
}
.disabled(neuesWort.trimmingCharacters(in: .whitespaces).isEmpty
|| vorgang.laeuft)
}
} header: {
Text(String(localized: "kategorien.erkennungswoerter"))
} footer: {
Text(String(localized: "kategorien.erkennungErklaerung"))
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
}
private func fuegeHinzu() async {
guard let client = zustand.client else { return }
let wort = neuesWort.trimmingCharacters(in: .whitespacesAndNewlines)
guard !wort.isEmpty else { return }
let geglueckt = await vorgang.fuehreAus {
_ = try await client.legeErkennungswortAn(
kategorie: kategorie.id, ErkennungswortAnlegen(word: wort))
}
if geglueckt {
neuesWort = ""
await lader?.aktualisiere()
// Die Kategorie führt die Anzahl mit; ohne das bleibt die
// Plakette in der Übersicht auf dem alten Stand stehen.
await zustand.ladeNachschlag()
}
}
private func loesche(_ wort: Erkennungswort) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) {
await lader?.aktualisiere()
await zustand.ladeNachschlag()
}
}
}
+168
View File
@@ -0,0 +1,168 @@
import SwiftUI
/// Schlagwörter: frei vergebene Etiketten, quer zur Kategorie.
///
/// Die Kategorie beantwortet „wofür war das Geld". Alles, was quer dazu
/// liegt – „Urlaub Island", „steuerlich absetzbar" –, passt in keine, weil
/// eine Buchung davon mehrere auf einmal sein kann.
struct TagsAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var vorgang = Speichervorgang()
@State private var bearbeitet: Schlagwort?
@State private var neuerName = ""
var body: some View {
List {
Section {
ForEach(zustand.nachschlag.schlagwoerter) { etikett in
Button {
bearbeitet = etikett
} label: {
HStack(spacing: 10) {
Circle()
.fill(Color(hex: etikett.color))
.frame(width: 14, height: 14)
Text(etikett.name)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.swipeActions {
Button(role: .destructive) {
Task { await loesche(etikett) }
} label: {
Label(String(localized: "allgemein.loeschen"), systemImage: "trash")
}
}
}
HStack {
TextField(String(localized: "tags.neu"), text: $neuerName)
.onSubmit { Task { await legeAn() } }
Button {
Task { await legeAn() }
} label: {
Image(systemName: "plus.circle.fill")
}
.disabled(neuerName.trimmingCharacters(in: .whitespaces).isEmpty
|| vorgang.laeuft)
}
} footer: {
Text(String(localized: "tags.erklaerung"))
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.listStyle(.insetGrouped)
.navigationTitle(String(localized: "nebenraeume.tags"))
.refreshable { await zustand.ladeNachschlag() }
.sheet(item: $bearbeitet) { etikett in
TagFormular(etikett: etikett) { Task { await zustand.ladeNachschlag() } }
.environment(zustand)
}
}
private func legeAn() async {
guard let client = zustand.client else { return }
let name = neuerName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
// Reihum eine Farbe, damit nicht alle grau sind. Ändern geht danach.
let farbe = Farbwahl.palette[zustand.nachschlag.schlagwoerter.count % Farbwahl.palette.count]
let geglueckt = await vorgang.fuehreAus {
_ = try await client.legeTagAn(TagAnlegen(name: name, color: farbe))
}
if geglueckt {
neuerName = ""
await zustand.ladeNachschlag()
}
}
private func loesche(_ etikett: Schlagwort) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await client.loescheTag(etikett.id) }) {
await zustand.ladeNachschlag()
}
}
}
/// Ein Schlagwort umbenennen oder umfärben.
struct TagFormular: View {
let etikett: Schlagwort
var fertig: () -> Void
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
@State private var vorgang = Speichervorgang()
@State private var name = ""
@State private var farbe = Farbwahl.standard
@State private var vorbelegt = false
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(localized: "konto.name"), text: $name)
}
Section {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(Farbwahl.palette, id: \.self) { wert in
Button { farbe = wert } label: {
Circle()
.fill(Color(hex: wert))
.frame(width: 28, height: 28)
.overlay {
if wert == farbe {
Circle().strokeBorder(.primary, lineWidth: 2)
}
}
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.navigationTitle(String(localized: "nebenraeume.tags"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|| vorgang.laeuft)
}
}
.onAppear {
guard !vorbelegt else { return }
vorbelegt = true
name = etikett.name
farbe = etikett.color
}
}
}
private func sichere() async {
guard let client = zustand.client else { return }
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
let geglueckt = await vorgang.fuehreAus {
_ = try await client.aendereTag(etikett.id,
TagAendern(name: sauber, color: farbe))
}
if geglueckt {
fertig()
schliesse()
}
}
}
+190
View File
@@ -0,0 +1,190 @@
import SwiftUI
/// Den zweiten Faktor einrichten.
///
/// Bis Stufe 5 verwies die App dafür in den Browser. Drei Schritte:
/// Geheimnis anlegen, in die Authenticator-App übernehmen, mit einem Code
/// scharfschalten.
///
/// **Abschalten geht hier nicht**, und das ist Absicht: Es verlangt das
/// Kontopasswort, und die App speichert keines. Wer es abschalten will,
/// macht das am Rechner – ein Vollzugang auf diesem Gerät soll den Schutz
/// des Kontos nicht aufheben können.
struct ZweiterFaktorAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<ZweiterFaktorStand>?
@State private var vorgang = Speichervorgang()
@State private var einrichtung: ZweiterFaktorEinrichtung?
@State private var code = ""
@State private var rettungscodes: [String]?
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { stand in
inhalt(stand)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "zweifaktor.titel"))
.navigationBarTitleDisplayMode(.inline)
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<ZweiterFaktorStand>(schluessel: "auth/2fa/status") {
try await client.zweiterFaktorStand()
}
lader = neuer
await neuer.lade()
}
.sheet(item: Binding(get: { rettungscodes.map(Codeliste.init) },
set: { if $0 == nil { rettungscodes = nil } })) { liste in
RettungscodeBlatt(codes: liste.codes) {
rettungscodes = nil
Task { await lader?.aktualisiere() }
}
}
}
/// Nur da, um die Codes an `.sheet(item:)` zu geben.
private struct Codeliste: Identifiable {
let codes: [String]
var id: String { codes.joined() }
}
@ViewBuilder
private func inhalt(_ stand: ZweiterFaktorStand) -> some View {
Form {
Section {
LabeledContent(String(localized: "zweifaktor.stand")) {
Text(stand.enabled ? String(localized: "zweifaktor.aktiv")
: String(localized: "zweifaktor.inaktiv"))
.foregroundStyle(stand.enabled ? .green : .secondary)
}
if stand.enabled {
LabeledContent(String(localized: "zweifaktor.rettungscodes"),
value: "\(stand.recoveryCodesLeft)")
}
} footer: {
Text(stand.enabled ? String(localized: "zweifaktor.aktivErklaerung")
: String(localized: "zweifaktor.inaktivErklaerung"))
}
if !stand.enabled {
if let einrichtung {
Section {
// Auf dem Telefon selbst ist ein QR-Code sinnlos – die
// Authenticator-App liegt daneben. Der Link führt
// direkt hinein, das Geheimnis steht zum Abtippen da.
Link(destination: URL(string: einrichtung.otpauthUri)
?? URL(string: "https://example.invalid")!) {
Label(String(localized: "zweifaktor.inAppOeffnen"),
systemImage: "arrow.up.forward.app")
}
LabeledContent(String(localized: "zweifaktor.geheimnis")) {
Text(einrichtung.secret)
.font(.footnote.monospaced())
.textSelection(.enabled)
}
} header: {
Text(String(localized: "zweifaktor.schrittEins"))
} footer: {
Text(String(localized: "zweifaktor.geheimnisErklaerung"))
}
Section {
TextField(String(localized: "zweifaktor.code"), text: $code)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.monospaced()
Button(String(localized: "zweifaktor.aktivieren")) {
Task { await aktiviere() }
}
.disabled(code.count < 6 || vorgang.laeuft)
} header: {
Text(String(localized: "zweifaktor.schrittZwei"))
}
} else {
Section {
Button(String(localized: "zweifaktor.einrichten")) {
Task { await starte() }
}
.disabled(vorgang.laeuft)
}
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
}
private func starte() async {
guard let client = zustand.client else { return }
var neu: ZweiterFaktorEinrichtung?
if await vorgang.fuehreAus({ neu = try await client.richteZweitenFaktorEin() }) {
einrichtung = neu
}
}
private func aktiviere() async {
guard let client = zustand.client else { return }
var codes: Rettungscodes?
let geglueckt = await vorgang.fuehreAus {
codes = try await client.aktiviereZweitenFaktor(code: code)
}
if geglueckt {
code = ""
einrichtung = nil
rettungscodes = codes?.codes
}
}
}
/// Die Rettungscodes – einmal zu sehen, danach nie wieder.
///
/// Deshalb lässt sich dieses Blatt nicht wegwischen: Wer es versehentlich
/// schliesst, bekommt neue nur mit dem Kontopasswort am Rechner.
private struct RettungscodeBlatt: View {
let codes: [String]
let fertig: () -> Void
@State private var abgehakt = false
var body: some View {
NavigationStack {
List {
Section {
ForEach(codes, id: \.self) { code in
Text(code)
.font(.body.monospaced())
.textSelection(.enabled)
}
} header: {
Text(String(localized: "zweifaktor.rettungscodes"))
} footer: {
Text(String(localized: "zweifaktor.rettungscodesErklaerung"))
}
Section {
ShareLink(item: codes.joined(separator: "\n")) {
Label(String(localized: "zweifaktor.codesSichern"),
systemImage: "square.and.arrow.up")
}
Toggle(String(localized: "zweifaktor.codesNotiert"), isOn: $abgehakt)
}
}
.navigationTitle(String(localized: "zweifaktor.aktiv"))
.navigationBarTitleDisplayMode(.inline)
.interactiveDismissDisabled()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.fertig"), action: fertig)
.disabled(!abgehakt)
}
}
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import Foundation
/// Was beim Reden mit dem Certain-Backend schiefgehen kann.
///
/// Die Fälle sind nach dem geschnitten, was die App damit *tun* muss – nicht
/// nach HTTP-Status. `pflichtOffen` führt auf einen anderen Bildschirm,
/// `konflikt` ist eine Rückfrage, die sich mit „trotzdem" wiederholen lässt,
/// alles übrige ist eine Meldung.
enum APIFehler: Error, LocalizedError, Sendable, Equatable {
/// Netz weg, Server nicht erreichbar, Zeitüberschreitung.
case keineVerbindung(String)
/// Unter der Adresse antwortet etwas, aber kein Certain.
case keinCertainServer
/// 401 – Marke ungültig oder widerrufen, Sitzung abgelaufen.
case nichtAngemeldet
/// 403 mit maschinenlesbarem Schlüssel: erst muss etwas erledigt werden.
case pflichtOffen(Pflicht)
/// 403 aus einem anderen Grund.
case verboten(String)
/// 404 – gibt es nicht, oder gehört jemand anderem.
case nichtGefunden
/// 409 – meistens eine Rückfrage, die sich mit `trotzdem = true`
/// wiederholen lässt. Der Text ist für den Nutzer geschrieben.
case konflikt(String)
/// 400 oder 422.
case ungueltig(String)
/// Alles übrige, was der Server sagt.
case server(status: Int, text: String)
/// Die Antwort passt nicht zu dem, was wir erwarten.
case antwortUnlesbar(String)
/// Was der Benutzer erledigen muss, bevor die API ihn durchlässt.
/// Die Werte sind die Schlüssel aus `backend/app/deps.py`.
enum Pflicht: String, Sendable {
case passwortWechseln = "password_change_required"
case zweiterFaktor = "mfa_setup_required"
}
var errorDescription: String? {
switch self {
case .keineVerbindung(let grund):
return String(format: String(localized: "fehler.keineVerbindung"), grund)
case .keinCertainServer:
return String(localized: "fehler.keinCertainServer")
case .nichtAngemeldet:
return String(localized: "fehler.nichtAngemeldet")
case .pflichtOffen(.passwortWechseln):
return String(localized: "fehler.passwortWechseln")
case .pflichtOffen(.zweiterFaktor):
return String(localized: "fehler.zweiterFaktor")
case .verboten(let text), .konflikt(let text), .ungueltig(let text):
return text
case .nichtGefunden:
return String(localized: "fehler.nichtGefunden")
case .server(let status, let text):
return text.isEmpty
? String(format: String(localized: "fehler.server"), status)
: text
case .antwortUnlesbar(let grund):
return String(format: String(localized: "fehler.antwortUnlesbar"), grund)
}
}
/// Ob ein erneuter Versuch überhaupt Sinn ergibt.
var lohntWiederholung: Bool {
switch self {
case .keineVerbindung, .server:
return true
default:
return false
}
}
}
/// Der Fehlerkörper des Backends.
///
/// `detail` ist dort mal eine Zeichenkette (`{"detail": "Nicht gefunden"}`),
/// mal eine Liste von Pydantic-Meldungen (bei 422). Beides muss hier durch,
/// sonst verliert die App bei jedem Eingabefehler die Meldung.
struct Fehlerantwort: Decodable, Sendable {
let detail: String
private enum Schluessel: String, CodingKey { case detail }
private struct Einzelfehler: Decodable {
let msg: String?
}
init(from decoder: Decoder) throws {
let behaelter = try decoder.container(keyedBy: Schluessel.self)
if let text = try? behaelter.decode(String.self, forKey: .detail) {
detail = text
} else if let liste = try? behaelter.decode([Einzelfehler].self, forKey: .detail) {
detail = liste.compactMap(\.msg).joined(separator: ", ")
} else {
detail = ""
}
}
/// Versucht, aus Antwortdaten eine Meldung zu ziehen. Schlägt das fehl,
/// kommt eine leere Zeichenkette – nie ein Fehler, denn wir sind hier
/// bereits im Fehlerfall.
static func ausDaten(_ daten: Data) -> String {
(try? JSONDecoder().decode(Fehlerantwort.self, from: daten))?.detail ?? ""
}
}
+173
View File
@@ -0,0 +1,173 @@
import CryptoKit
import Foundation
import Security
/// Wo die App ihre Einstellungen und ihr Geheimnis hinlegt.
///
/// Zwei getrennte Orte, mit Absicht: Die Server-Adresse ist keine geheime
/// Angabe und liegt in den Voreinstellungen. Die Gerätemarke ist ein
/// Vollzugang und gehört in den Schlüsselbund – dort überlebt sie kein
/// unverschlüsseltes Backup und keine Gerätesicherung ohne Passcode.
enum Ablage {
/// Setze das hier auf deine App-Gruppe, sobald es Erweiterungen gibt
/// (`group.<deine-bundle-id>`). Solange `nil`, benutzt die App ihren
/// eigenen Bereich – das genügt, bis Teilen-Menü und Kurzbefehle dazukommen.
static let gruppe: String? = nil
/// Dieselbe Gruppe für den Schlüsselbund. Erst nötig, wenn eine
/// Erweiterung an dieselbe Marke muss.
static let schluesselbundGruppe: String? = nil
private static var voreinstellungen: UserDefaults {
if let gruppe, let geteilt = UserDefaults(suiteName: gruppe) { return geteilt }
return .standard
}
private enum Schluessel {
static let serverAdresse = "certain.serverAdresse"
static let marke = "certain.marke"
static let benutzername = "certain.benutzername"
static let pin = "certain.pin"
}
// MARK: - Server-Adresse
static var serverAdresse: URL? {
get {
guard let text = voreinstellungen.string(forKey: Schluessel.serverAdresse) else {
return nil
}
return URL(string: text)
}
set {
if let neue = newValue {
voreinstellungen.set(neue.absoluteString, forKey: Schluessel.serverAdresse)
} else {
voreinstellungen.removeObject(forKey: Schluessel.serverAdresse)
}
}
}
/// Nur zur Anzeige („angemeldet als …"), nicht zur Prüfung.
static var benutzername: String? {
get { voreinstellungen.string(forKey: Schluessel.benutzername) }
set {
if let neuer = newValue {
voreinstellungen.set(neuer, forKey: Schluessel.benutzername)
} else {
voreinstellungen.removeObject(forKey: Schluessel.benutzername)
}
}
}
// MARK: - Gerätemarke im Schlüsselbund
static var marke: String? {
get { schluesselbundLesen(Schluessel.marke) }
set {
if let neue = newValue {
schluesselbundSchreiben(Schluessel.marke, neue)
} else {
schluesselbundLoeschen(Schluessel.marke)
}
}
}
// MARK: - Bildschirmsperre
/// Die PIN selbst wird nie abgelegt, nur ein gesalzener SHA-256 davon.
/// Das Salz liegt daneben, beides im Schlüsselbund als `salz:hash` in Hex.
///
/// Die PIN verdeckt die Oberfläche, sie verschlüsselt nicht die
/// Gerätemarke – wer das Telefon entsperrt hat und es auslesen kann,
/// käme an die Marke. Gegen Mitlesen über die Schulter und gegen ein
/// kurz liegengelassenes Telefon hilft sie, gegen Forensik nicht.
static var hatPIN: Bool { schluesselbundLesen(Schluessel.pin) != nil }
static func setzePIN(_ pin: String) {
var salz = Data(count: 16)
_ = salz.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
schluesselbundSchreiben(Schluessel.pin, "\(hex(salz)):\(hex(verhashe(pin, salz: salz)))")
}
static func stimmtPIN(_ pin: String) -> Bool {
guard let abgelegt = schluesselbundLesen(Schluessel.pin) else { return false }
let teile = abgelegt.split(separator: ":", maxSplits: 1)
guard teile.count == 2, let salz = daten(hex: String(teile[0])) else { return false }
return hex(verhashe(pin, salz: salz)) == String(teile[1])
}
static func loeschePIN() { schluesselbundLoeschen(Schluessel.pin) }
private static func verhashe(_ pin: String, salz: Data) -> Data {
Data(SHA256.hash(data: salz + Data(pin.utf8)))
}
private static func hex(_ daten: Data) -> String {
daten.map { String(format: "%02x", $0) }.joined()
}
private static func daten(hex text: String) -> Data? {
guard text.count % 2 == 0 else { return nil }
var roh = Data(); var rest = Substring(text)
while !rest.isEmpty {
guard let wert = UInt8(rest.prefix(2), radix: 16) else { return nil }
roh.append(wert); rest = rest.dropFirst(2)
}
return roh
}
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
static func raeumeAuf() {
marke = nil
loeschePIN()
benutzername = nil
serverAdresse = nil
Zwischenspeicher.leere()
}
// MARK: - Schlüsselbund, schmucklos
private static func grundlage(_ konto: String) -> [String: Any] {
var abfrage: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.certain.app",
kSecAttrAccount as String: konto,
]
if let schluesselbundGruppe {
abfrage[kSecAttrAccessGroup as String] = schluesselbundGruppe
}
return abfrage
}
private static func schluesselbundLesen(_ konto: String) -> String? {
var abfrage = grundlage(konto)
abfrage[kSecReturnData as String] = true
abfrage[kSecMatchLimit as String] = kSecMatchLimitOne
var ergebnis: CFTypeRef?
let status = SecItemCopyMatching(abfrage as CFDictionary, &ergebnis)
guard status == errSecSuccess, let daten = ergebnis as? Data else { return nil }
return String(data: daten, encoding: .utf8)
}
private static func schluesselbundSchreiben(_ konto: String, _ wert: String) {
let abfrage = grundlage(konto)
let inhalt: [String: Any] = [
kSecValueData as String: Data(wert.utf8),
// Nicht in ein Backup und nicht auf ein anderes Gerät: Die Marke
// gehoert genau diesem Telefon.
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let status = SecItemUpdate(abfrage as CFDictionary, inhalt as CFDictionary)
if status == errSecItemNotFound {
var neu = abfrage
neu.merge(inhalt) { _, zweites in zweites }
SecItemAdd(neu as CFDictionary, nil)
}
}
private static func schluesselbundLoeschen(_ konto: String) {
SecItemDelete(grundlage(konto) as CFDictionary)
}
}
+235
View File
@@ -0,0 +1,235 @@
import Foundation
/// Der einzige Weg der App zum Backend.
///
/// Ein Actor, weil Adresse und Marke sich zur Laufzeit ändern (an- und
/// abmelden) und von überall gelesen werden.
///
/// Zwei Anmeldewege, und zwar nacheinander, nicht nebeneinander:
/// Beim Anmelden setzt das Backend ein Sitzungs-Cookie, das `URLSession`
/// von selbst mitführt. Noch in dieser Sitzung holt sich die App eine
/// Gerätemarke und benutzt danach nur noch den Header `X-Certain-Token`.
/// Das Cookie wird dann weggeworfen – siehe `vergissCookies()`.
actor CertainClient {
private(set) var basis: URL
private(set) var marke: String?
private let sitzung: URLSession
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
init(basis: URL, marke: String? = nil) {
self.basis = basis
self.marke = marke
let einstellungen = URLSessionConfiguration.ephemeral
einstellungen.httpCookieStorage = HTTPCookieStorage.shared
einstellungen.httpShouldSetCookies = true
einstellungen.httpCookieAcceptPolicy = .always
// Alle /api-Antworten tragen `Cache-Control: no-store`; den lokalen
// Zwischenspeicher führen wir selbst (siehe `Zwischenspeicher`), weil
// wir ihn auch offline und mit Datumsangabe brauchen.
einstellungen.requestCachePolicy = .reloadIgnoringLocalCacheData
einstellungen.timeoutIntervalForRequest = 30
einstellungen.timeoutIntervalForResource = 120
einstellungen.waitsForConnectivity = false
sitzung = URLSession(configuration: einstellungen)
}
// MARK: - Zustand
func setzeBasis(_ neue: URL) {
basis = neue
}
func setzeMarke(_ neue: String?) {
marke = neue
}
/// Nach dem Holen der Marke: Das Cookie wird nicht mehr gebraucht und soll
/// nicht länger auf dem Gerät liegen als nötig.
func vergissCookies() {
guard let speicher = sitzung.configuration.httpCookieStorage else { return }
for keks in speicher.cookies(for: basis) ?? [] {
speicher.deleteCookie(keks)
}
}
// MARK: - Aufrufe
enum Methode: String, Sendable {
case get = "GET"
case post = "POST"
case patch = "PATCH"
case put = "PUT"
case delete = "DELETE"
}
/// Lesen.
func hole<A: Decodable>(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> A {
let daten = try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil)
return try entschluessele(daten)
}
/// Schreiben ohne Nutzdaten (etwa `POST /uebertraege/suchen`).
@discardableResult
func schicke<A: Decodable>(_ methode: Methode, _ pfad: String,
abfrage: [URLQueryItem] = []) async throws -> A {
let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil)
return try entschluessele(daten)
}
/// Schreiben mit JSON-Körper.
@discardableResult
func schicke<A: Decodable, K: Encodable>(_ methode: Methode, _ pfad: String,
koerper: K,
abfrage: [URLQueryItem] = []) async throws -> A {
let roh = try encoder.encode(koerper)
let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh)
return try entschluessele(daten)
}
/// Für 204-Antworten: Es kommt nichts zurück, und das ist richtig so.
func schickeStumm(_ methode: Methode, _ pfad: String,
abfrage: [URLQueryItem] = []) async throws {
_ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil)
}
func schickeStumm<K: Encodable>(_ methode: Methode, _ pfad: String,
koerper: K,
abfrage: [URLQueryItem] = []) async throws {
let roh = try encoder.encode(koerper)
_ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh)
}
/// Die Anmeldung geht als Formular, nicht als JSON – das Backend nutzt
/// `OAuth2PasswordRequestForm` (`backend/app/routers/auth.py`).
func schickeFormular<A: Decodable>(_ pfad: String,
felder: [String: String]) async throws -> A {
var teile = URLComponents()
teile.queryItems = felder.map { URLQueryItem(name: $0.key, value: $0.value) }
// `+` muss als Zeichen durch, sonst wird daraus beim Server ein Leerzeichen.
let text = (teile.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B")
let daten = try await fuehreAus(
.post, pfad, abfrage: [], koerper: Data(text.utf8),
inhaltstyp: "application/x-www-form-urlencoded")
return try entschluessele(daten)
}
/// Datei hochladen (Logos, Kontoauszüge, Belege).
func schickeDatei<A: Decodable>(_ pfad: String, feldname: String = "file",
dateiname: String, inhalt: Data,
typ: String = "application/octet-stream",
weitereFelder: [String: String] = [:],
abfrage: [URLQueryItem] = []) async throws -> A {
let grenze = "Grenze-\(UUID().uuidString)"
var koerper = Data()
func anhaengen(_ text: String) { koerper.append(Data(text.utf8)) }
for (schluessel, wert) in weitereFelder {
anhaengen("--\(grenze)\r\n")
anhaengen("Content-Disposition: form-data; name=\"\(schluessel)\"\r\n\r\n")
anhaengen("\(wert)\r\n")
}
anhaengen("--\(grenze)\r\n")
anhaengen("Content-Disposition: form-data; name=\"\(feldname)\"; filename=\"\(dateiname)\"\r\n")
anhaengen("Content-Type: \(typ)\r\n\r\n")
koerper.append(inhalt)
anhaengen("\r\n--\(grenze)--\r\n")
let daten = try await fuehreAus(
.post, pfad, abfrage: abfrage, koerper: koerper,
inhaltstyp: "multipart/form-data; boundary=\(grenze)")
return try entschluessele(daten)
}
/// Rohe Bytes holen – für Bilder (Abo-Logos) und Dateien.
func holeBytes(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> Data {
try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil)
}
// MARK: - Innenleben
private func baueAdresse(_ pfad: String, _ abfrage: [URLQueryItem]) throws -> URL {
let voll = basis.appendingPathComponent("api").appendingPathComponent(
pfad.hasPrefix("/") ? String(pfad.dropFirst()) : pfad)
guard var teile = URLComponents(url: voll, resolvingAgainstBaseURL: false) else {
throw APIFehler.antwortUnlesbar(pfad)
}
// Leere Werte gar nicht erst mitschicken – das Backend liest "" sonst
// als gesetzten Filter.
let gefiltert = abfrage.filter { ($0.value ?? "").isEmpty == false }
teile.queryItems = gefiltert.isEmpty ? nil : gefiltert
guard let fertig = teile.url else { throw APIFehler.antwortUnlesbar(pfad) }
return fertig
}
private func fuehreAus(_ methode: Methode, _ pfad: String,
abfrage: [URLQueryItem], koerper: Data?,
inhaltstyp: String = "application/json") async throws -> Data {
var anfrage = URLRequest(url: try baueAdresse(pfad, abfrage))
anfrage.httpMethod = methode.rawValue
anfrage.setValue("application/json", forHTTPHeaderField: "Accept")
if let marke {
anfrage.setValue(marke, forHTTPHeaderField: "X-Certain-Token")
}
if let koerper {
anfrage.httpBody = koerper
anfrage.setValue(inhaltstyp, forHTTPHeaderField: "Content-Type")
}
let daten: Data
let antwort: URLResponse
do {
(daten, antwort) = try await sitzung.data(for: anfrage)
} catch let fehler as URLError {
throw APIFehler.keineVerbindung(fehler.localizedDescription)
} catch {
throw APIFehler.keineVerbindung(error.localizedDescription)
}
guard let http = antwort as? HTTPURLResponse else {
throw APIFehler.antwortUnlesbar("keine HTTP-Antwort")
}
guard (200..<300).contains(http.statusCode) else {
throw deute(status: http.statusCode, daten: daten)
}
return daten
}
private func deute(status: Int, daten: Data) -> APIFehler {
let text = Fehlerantwort.ausDaten(daten)
switch status {
case 401:
return .nichtAngemeldet
case 403:
if let pflicht = APIFehler.Pflicht(rawValue: text) {
return .pflichtOffen(pflicht)
}
return .verboten(text)
case 404:
return .nichtGefunden
case 409:
return .konflikt(text)
case 400, 422:
return .ungueltig(text)
default:
return .server(status: status, text: text)
}
}
private func entschluessele<A: Decodable>(_ daten: Data) throws -> A {
// 204 und andere leere Antworten: Wer hier `Leer` erwartet, bekommt es.
if daten.isEmpty, let leer = Leer() as? A { return leer }
do {
return try decoder.decode(A.self, from: daten)
} catch {
throw APIFehler.antwortUnlesbar(String(describing: error))
}
}
}
/// Platzhalter für Antworten ohne Inhalt.
struct Leer: Codable, Sendable {
init() {}
}
+475
View File
@@ -0,0 +1,475 @@
import Foundation
// Alle Pfade an einer Stelle. An der Aufrufstelle steht nie eine Zeichenkette –
// wer einen Endpunkt umbenennt, bricht genau hier und nicht in zwölf Ansichten.
//
// Stufe 1: Server prüfen, anmelden, Marke holen, Konten lesen.
extension CertainClient {
// MARK: - Server
/// Prüft, ob unter der Adresse überhaupt ein Certain läuft.
///
/// Wichtig: Das Backend liefert für jeden unbekannten Pfad ausserhalb von
/// `/api` die Weboberfläche mit Status 200 aus. `/api/version` kann das
/// nicht – nur ein echter Server antwortet hier mit dem erwarteten JSON.
func serverkennung() async throws -> Serverkennung {
let kennung: Serverkennung
do {
kennung = try await hole("version")
} catch APIFehler.antwortUnlesbar {
throw APIFehler.keinCertainServer
}
guard kennung.istCertain else { throw APIFehler.keinCertainServer }
return kennung
}
// MARK: - Anmeldung
/// Meldet an und bekommt ein Sitzungs-Cookie. Steht der zweite Faktor
/// aus, ist `status == "2fa_required"` und das Cookie gilt nur zehn
/// Minuten für genau diesen Schritt.
func melde(benutzer: String, passwort: String) async throws -> AnmeldeAntwort {
try await schickeFormular("auth/login",
felder: ["username": benutzer, "password": passwort])
}
func pruefeZweitenFaktor(code: String, wiederherstellung: Bool = false) async throws -> AnmeldeAntwort {
try await schicke(.post, "auth/2fa/verify",
koerper: ZweiterFaktorEingabe(code: code, recovery: wiederherstellung))
}
func ichSelbst() async throws -> Benutzer {
try await hole("auth/me")
}
/// Wechselt das Passwort. Beendet dabei alle anderen Sitzungen des Kontos
/// und setzt für den Aufrufer ein frisches Cookie.
@discardableResult
func wechslePasswort(alt: String, neu: String) async throws -> Benutzer {
try await schicke(.post, "auth/change-password",
koerper: PasswortWechsel(currentPassword: alt, newPassword: neu))
}
func meldeAb() async throws {
try await schickeStumm(.post, "auth/logout")
}
// MARK: - Gerätemarken
/// Holt die Marke, mit der die App von da an arbeitet. Geht nur aus einer
/// echten Anmeldung heraus – eine Marke darf keine weitere ausstellen.
func legeAppMarkeAn(name: String) async throws -> Marke {
try await schicke(.post, "inbox/tokens", koerper: MarkeAnlegen.fuerApp(name: name))
}
func marken() async throws -> [Marke] {
try await hole("inbox/tokens")
}
func widerrufeMarke(id: Int) async throws {
try await schickeStumm(.delete, "inbox/tokens/\(id)")
}
// MARK: - Einstellungen
func einstellungen() async throws -> Einstellungen {
let roh: [Einstellung] = try await hole("settings")
return Einstellungen(roh)
}
@discardableResult
func setzeEinstellung(_ schluessel: String, _ wert: String) async throws -> Einstellung {
// Query statt Körper – so will es das Backend (`routers/settings.py`).
try await schicke(.put, "settings/\(schluessel)",
abfrage: [URLQueryItem(name: "value", value: wert)])
}
// MARK: - Konten
func konten() async throws -> [Konto] {
try await hole("accounts")
}
func konto(_ id: Int) async throws -> Konto {
try await hole("accounts/\(id)")
}
// MARK: - Kategorien und Schlagwörter
func kategoriegruppen() async throws -> [Kategoriegruppe] {
try await hole("categories")
}
func schlagwoerter() async throws -> [Schlagwort] {
try await hole("tags")
}
/// Das Nachschlagewerk, das fast jede Liste braucht: Kategorien, Tags und
/// Konten in einem Rutsch. Drei Abrufe nebeneinander statt nacheinander –
/// sie wissen nichts voneinander.
func nachschlag() async throws -> Nachschlag {
async let gruppen = kategoriegruppen()
async let tags = schlagwoerter()
async let alleKonten = konten()
return try await Nachschlag(gruppen: gruppen, schlagwoerter: tags, konten: alleKonten)
}
// MARK: - Buchungen
/// - Parameter suche: Volltext über Empfänger, Verwendungszweck und Notiz.
/// Seit der Backend-Ergänzung siebt der Server, nicht das Telefon.
func buchungen(konto: Int? = nil, kategorie: Int? = nil, art: Buchungsart? = nil,
von: Tagesdatum? = nil, bis: Tagesdatum? = nil,
suche: String? = nil, schlagwort: Int? = nil, abo: Int? = nil,
ohneKategorie: Bool = false, grenze: Int = 200) async throws -> [Buchung] {
var abfrage: [URLQueryItem] = [URLQueryItem(name: "limit", value: String(grenze))]
func dazu(_ name: String, _ wert: String?) {
guard let wert, !wert.isEmpty else { return }
abfrage.append(URLQueryItem(name: name, value: wert))
}
dazu("account_id", konto.map(String.init))
dazu("category_id", kategorie.map(String.init))
dazu("type", art?.rawValue)
dazu("date_from", von?.text)
dazu("date_to", bis?.text)
dazu("q", suche)
dazu("tag_id", schlagwort.map(String.init))
dazu("subscription_id", abo.map(String.init))
if ohneKategorie { dazu("uncategorized", "true") }
return try await hole("transactions", abfrage: abfrage)
}
func buchung(_ id: Int) async throws -> Buchung {
try await hole("transactions/\(id)")
}
/// Je Buchungs-ID die Gegenseite, wenn zwei Buchungen zu einem Übertrag
/// verknüpft sind. Der Schlüssel ist die ID als Zeichenkette – JSON kennt
/// keine Zahlen als Schlüssel.
func uebertragsverknuepfungen() async throws -> [String: Uebertragsverknuepfung] {
try await hole("transactions/links")
}
// MARK: - Budget
func budget(_ label: String? = nil) async throws -> Budgetmonat {
try await hole(label.map { "budget/\($0)" } ?? "budget/current")
}
func budgetvorlage() async throws -> Budgetvorlage {
try await hole("budget/template")
}
// MARK: - Verträge
func abos() async throws -> Aboliste {
try await hole("subscriptions")
}
func abo(_ id: Int) async throws -> Abo {
try await hole("subscriptions/\(id)")
}
// MARK: - Dashboard
func kontostaende() async throws -> Kontostaende {
try await hole("dashboard/balances")
}
func budgetfortschritt() async throws -> Budgetfortschritt {
try await hole("dashboard/budget-progress")
}
func einnahmenAusgaben() async throws -> EinnahmenAusgaben {
try await hole("dashboard/income-expense")
}
func letzteBuchungen(grenze: Int = 8) async throws -> LetzteBuchungen {
try await hole("dashboard/recent",
abfrage: [URLQueryItem(name: "limit", value: String(grenze))])
}
// MARK: - Schreiben: Buchungen
func legeBuchungAn(_ eingabe: BuchungAnlegen) async throws -> Buchung {
try await schicke(.post, "transactions", koerper: eingabe)
}
func aendereBuchung(_ id: Int, _ aenderung: BuchungAendern) async throws -> Buchung {
try await schicke(.patch, "transactions/\(id)", koerper: aenderung)
}
func loescheBuchung(_ id: Int) async throws {
try await schickeStumm(.delete, "transactions/\(id)")
}
// MARK: - Schreiben: Budget
/// Setzt eine Zeile der Monatsansicht. Der Monat wird dadurch als
/// „angepasst" geführt und folgt der Vorlage nicht mehr.
///
/// Query statt Körper – so will es das Backend (`routers/budgets.py`).
@discardableResult
func setzeBudgetMonat(_ label: String, kategorie: Int, geplantCents: Int?,
modus: String? = nil,
zuschlagCents: Int? = nil) async throws -> Budgetmonat {
try await schicke(.put, "budget/\(label)/category/\(kategorie)",
abfrage: budgetAbfrage(geplantCents, modus, zuschlagCents))
}
/// Setzt eine Zeile der Standard-Vorlage. Wirkt auf alle Monate, die
/// nicht eigens angepasst sind.
@discardableResult
func setzeBudgetVorlage(kategorie: Int, geplantCents: Int?, modus: String? = nil,
zuschlagCents: Int? = nil) async throws -> Vorlagenzeile {
try await schicke(.put, "budget/template/category/\(kategorie)",
abfrage: budgetAbfrage(geplantCents, modus, zuschlagCents))
}
private func budgetAbfrage(_ geplantCents: Int?, _ modus: String?,
_ zuschlagCents: Int?) -> [URLQueryItem] {
var abfrage: [URLQueryItem] = []
if let geplantCents {
abfrage.append(URLQueryItem(name: "planned_cents", value: String(geplantCents)))
}
if let modus {
abfrage.append(URLQueryItem(name: "modus", value: modus))
}
if let zuschlagCents {
abfrage.append(URLQueryItem(name: "zuschlag_cents", value: String(zuschlagCents)))
}
return abfrage
}
/// Nimmt die Anpassungen dieses Monats zurück – er folgt wieder der Vorlage.
@discardableResult
func setzeBudgetMonatZurueck(_ label: String) async throws -> Budgetmonat {
try await schicke(.delete, "budget/\(label)")
}
/// Überschreibt den Monat mit der Vorlage.
@discardableResult
func holeVorlageInMonat(_ label: String) async throws -> Budgetmonat {
try await schicke(.post, "budget/\(label)/from-template")
}
/// Macht diesen Monat zur neuen Vorlage.
@discardableResult
func macheMonatZurVorlage(_ label: String) async throws -> Budgetvorlage {
try await schicke(.post, "budget/template/from-month/\(label)")
}
// MARK: - Schreiben: Verträge
func legeAboAn(_ eingabe: AboAnlegen) async throws -> Abo {
try await schicke(.post, "subscriptions", koerper: eingabe)
}
func aendereAbo(_ id: Int, _ aenderung: AboAendern) async throws -> Abo {
try await schicke(.patch, "subscriptions/\(id)", koerper: aenderung)
}
func loescheAbo(_ id: Int) async throws {
try await schickeStumm(.delete, "subscriptions/\(id)")
}
// MARK: - Schreiben: Konten
func legeKontoAn(_ eingabe: KontoAnlegen) async throws -> Konto {
try await schicke(.post, "accounts", koerper: eingabe)
}
func aendereKonto(_ id: Int, _ aenderung: KontoAendern) async throws -> Konto {
try await schicke(.patch, "accounts/\(id)", koerper: aenderung)
}
func loescheKonto(_ id: Int) async throws {
try await schickeStumm(.delete, "accounts/\(id)")
}
// MARK: - Arbeitsliste: Eingang
func eingang(grenze: Int = 200) async throws -> [Eingangseintrag] {
try await hole("inbox", abfrage: [URLQueryItem(name: "limit", value: String(grenze))])
}
/// Macht aus dem Eintrag eine Buchung. Braucht ein Konto – sonst 400.
@discardableResult
func bucheEingang(_ id: Int) async throws -> Eingangseintrag {
try await schicke(.post, "inbox/\(id)/book")
}
/// Bucht den Eintrag als Kontoübertrag statt als Ausgabe oder Einnahme.
///
/// Hat das Backend Bedenken, antwortet es mit 409 (`APIFehler.konflikt`).
/// Dann den Nutzer fragen und mit `trotzdem: true` wiederholen – nicht
/// stillschweigend übergehen und nicht einfach aufgeben.
@discardableResult
func bucheEingangAlsUebertrag(_ id: Int, aufKonto: Int,
trotzdem: Bool = false) async throws -> Eingangseintrag {
try await schicke(.post, "inbox/\(id)/transfer",
koerper: AlsUebertragBuchen(toAccountId: aufKonto,
trotzdem: trotzdem))
}
/// Alle offenen Einträge buchen, die ein Konto haben. Unklare bleiben
/// liegen – die soll man ansehen, nicht im Stapel durchwinken.
@discardableResult
func bucheGanzenEingang() async throws -> SammelErgebnis {
try await schicke(.post, "inbox/book-all")
}
/// Verwirft den Eintrag samt der Buchung, die daraus entstanden ist.
func verwerfeEingang(_ id: Int) async throws {
try await schickeStumm(.delete, "inbox/\(id)")
}
/// Setzt die Kategorie auf dem Eintrag **und** auf der erzeugten Buchung.
@discardableResult
func setzeEingangsKategorie(_ id: Int, kategorie: Int) async throws -> Eingangseintrag {
try await schicke(.post, "inbox/\(id)/category/\(kategorie)")
}
// MARK: - Arbeitsliste: Überträge
/// Fragt nur – beim Hinsehen wird nichts verknüpft.
func uebertragsvorschlaege() async throws -> Uebertragsvorschlaege {
try await hole("uebertraege/vorschlaege")
}
/// Sucht aktiv: Eindeutige Paare mit Übertrags-Hinweis werden dabei
/// verknüpft, der Rest kommt als Vorschlag zurück.
@discardableResult
func sucheUebertraege() async throws -> Uebertragsvorschlaege {
try await schicke(.post, "uebertraege/suchen")
}
func verbindeUebertrag(_ a: Int, _ b: Int) async throws {
try await schickeStumm(.post, "uebertraege/verbinden",
koerper: Buchungspaar(aId: a, bId: b))
}
/// Lehnt einen Vorschlag ab, damit er bei jedem weiteren Suchlauf
/// wegbleibt.
func lehneUebertragAb(_ a: Int, _ b: Int) async throws {
try await schickeStumm(.post, "uebertraege/ignorieren",
koerper: Buchungspaar(aId: a, bId: b))
}
// MARK: - Arbeitsliste: Dubletten
func dubletten() async throws -> Dublettengruppen {
try await hole("transactions/dubletten")
}
/// „Sieht gleich aus, ist aber richtig so." Die Gruppe verschwindet erst,
/// wenn **alle** ihre Zeilen den Haken tragen.
func behalteDubletten(_ ids: [Int]) async throws {
try await schickeStumm(.post, "transactions/dubletten/behalten",
koerper: ["ids": ids])
}
// MARK: - Kategorien schreiben
func legeKategorieAn(_ eingabe: KategorieAnlegen) async throws -> Kategorie {
try await schicke(.post, "categories", koerper: eingabe)
}
func aendereKategorie(_ id: Int, _ aenderung: KategorieAendern) async throws -> Kategorie {
try await schicke(.patch, "categories/\(id)", koerper: aenderung)
}
/// Geht nur, solange keine Buchung daran hängt – sonst 400. Archivieren
/// ist der Weg für alles, was schon benutzt wurde.
func loescheKategorie(_ id: Int) async throws {
try await schickeStumm(.delete, "categories/\(id)")
}
func legeGruppeAn(_ eingabe: GruppeAnlegen) async throws -> Kategoriegruppe {
try await schicke(.post, "categories/groups", koerper: eingabe)
}
func aendereGruppe(_ id: Int, _ aenderung: GruppeAendern) async throws -> Kategoriegruppe {
try await schicke(.patch, "categories/groups/\(id)", koerper: aenderung)
}
func loescheGruppe(_ id: Int) async throws {
try await schickeStumm(.delete, "categories/groups/\(id)")
}
// MARK: - Schlagwörter (Tags) schreiben
func legeTagAn(_ eingabe: TagAnlegen) async throws -> Schlagwort {
try await schicke(.post, "tags", koerper: eingabe)
}
func aendereTag(_ id: Int, _ aenderung: TagAendern) async throws -> Schlagwort {
try await schicke(.patch, "tags/\(id)", koerper: aenderung)
}
func loescheTag(_ id: Int) async throws {
try await schickeStumm(.delete, "tags/\(id)")
}
// MARK: - Erkennungswörter
func erkennungswoerter(kategorie: Int) async throws -> [Erkennungswort] {
try await hole("categories/\(kategorie)/keywords")
}
func legeErkennungswortAn(kategorie: Int,
_ eingabe: ErkennungswortAnlegen) async throws -> Erkennungswort {
try await schicke(.post, "categories/\(kategorie)/keywords", koerper: eingabe)
}
func loescheErkennungswort(_ id: Int) async throws {
try await schickeStumm(.delete, "categories/keywords/\(id)")
}
/// Wörter, die oft wieder korrigiert werden – zur Prüfung gemeldet.
func markierteErkennungswoerter() async throws -> [MarkiertesWort] {
try await hole("categories/keywords/flagged")
}
/// „Behalten": Das Wort stimmt, die Korrekturen waren Einzelfälle.
func bestaetigeErkennungswort(_ id: Int) async throws {
try await schickeStumm(.post, "categories/keywords/\(id)/confirm")
}
// MARK: - Zweiter Faktor
func zweiterFaktorStand() async throws -> ZweiterFaktorStand {
try await hole("auth/2fa/status")
}
/// Legt ein noch **nicht** aktives Geheimnis an. Gültig wird es erst mit
/// `aktiviereZweitenFaktor`.
func richteZweitenFaktorEin() async throws -> ZweiterFaktorEinrichtung {
try await schicke(.post, "auth/2fa/setup")
}
/// Prüft einen Code aus der Authenticator-App und schaltet scharf.
/// Die Rettungscodes kommen dabei **einmal** – danach nie wieder.
func aktiviereZweitenFaktor(code: String) async throws -> Rettungscodes {
try await schicke(.post, "auth/2fa/activate",
koerper: ZweiterFaktorCode(code: code))
}
// MARK: - Bank
func bankverbindungen() async throws -> [Bankverbindung] {
try await hole("bank/connections")
}
/// Holt die Umsätze dieser Verbindung in den Eingang.
func rufeBankAb(_ verbindung: Int) async throws -> Abrufergebnis {
try await schicke(.post, "bank/connections/\(verbindung)/sync")
}
/// Nur die Salden auffrischen, ohne Umsätze zu holen.
func frischeSaldenAuf(_ verbindung: Int) async throws {
try await schickeStumm(.post, "bank/connections/\(verbindung)/salden")
}
}
+57
View File
@@ -0,0 +1,57 @@
import Foundation
import Observation
/// Holt eine Liste, legt sie in den Zwischenspeicher und weiss, was bei einem
/// Fehlschlag noch da ist.
///
/// Jede Listenansicht braucht dasselbe: laden, Fehler zeigen, zum Aktualisieren
/// herunterziehen, offline den letzten Stand behalten. Einmal hier, statt in
/// jeder Ansicht neu.
@MainActor
@Observable
final class Lader<A: Codable & Sendable> {
private(set) var zustand: Ladezustand<A> = .laedt
private let schluessel: String
private let holen: () async throws -> A
/// - Parameter schluessel: Name im Zwischenspeicher. Der Endpunktpfad ist
/// eine gute Wahl – er ist eindeutig und beim Suchen lesbar.
init(schluessel: String, holen: @escaping () async throws -> A) {
self.schluessel = schluessel
self.holen = holen
}
/// Lädt. Beim ersten Mal wird ein vorhandener Stand sofort angezeigt,
/// damit die Liste nicht leer aufblitzt, während der Abruf läuft.
func lade(zeigeGemerktesSofort: Bool = true) async {
if zeigeGemerktesSofort, zustand.inhalt == nil,
let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) {
zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm,
fehler: .keineVerbindung(""))
} else if zustand.inhalt == nil {
zustand = .laedt
}
do {
let frisch = try await holen()
Zwischenspeicher.lege(frisch, unter: schluessel)
zustand = .da(frisch)
} catch let fehler as APIFehler {
if let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) {
zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm,
fehler: fehler)
} else {
zustand = .fehler(fehler)
}
} catch {
zustand = .fehler(.keineVerbindung(error.localizedDescription))
}
}
/// Fürs Herunterziehen: kein Zwischenzustand, die alte Liste bleibt
/// stehen, bis die neue da ist.
func aktualisiere() async {
await lade(zeigeGemerktesSofort: false)
}
}
+83
View File
@@ -0,0 +1,83 @@
import Foundation
/// Die letzte geglückte Antwort je Abruf, auf der Platte.
///
/// Wozu: In der U-Bahn und im Ausland soll die App nicht mit einer
/// Fehlermeldung dastehen, sondern den letzten Stand zeigen – mit Datum, damit
/// niemand alte Zahlen für aktuelle hält. Geschrieben wird offline nicht.
///
/// Bewusst schlicht: eine JSON-Datei je Schlüssel, kein Verfallsdatum, kein
/// Aufräumen nach Grösse. Die Antworten sind klein (die grösste, die
/// Buchungsliste, liegt im einstelligen Megabyte-Bereich), und ein Abmelden
/// räumt ohnehin alles weg.
enum Zwischenspeicher {
/// Was aus dem Speicher kam, und wann es geholt wurde.
struct Eintrag<A>: Sendable where A: Sendable {
let wert: A
let geholtAm: Date
}
private static let ordner: URL? = {
guard let basis = try? FileManager.default.url(
for: .cachesDirectory, in: .userDomainMask,
appropriateFor: nil, create: true) else { return nil }
let eigener = basis.appendingPathComponent("CertainAntworten", isDirectory: true)
try? FileManager.default.createDirectory(at: eigener, withIntermediateDirectories: true)
return eigener
}()
private static func datei(_ schluessel: String) -> URL? {
// Der Schluessel ist ein Pfad mit Abfrage; daraus muss ein gueltiger
// Dateiname werden.
let sauber = schluessel.unicodeScalars.map { zeichen -> Character in
CharacterSet.alphanumerics.contains(zeichen) ? Character(zeichen) : "_"
}
return ordner?.appendingPathComponent(String(sauber) + ".json")
}
static func lege<A: Encodable>(_ wert: A, unter schluessel: String) {
guard let ziel = datei(schluessel),
let daten = try? JSONEncoder().encode(wert) else { return }
try? daten.write(to: ziel, options: .atomic)
}
static func hole<A: Decodable & Sendable>(_ typ: A.Type, unter schluessel: String) -> Eintrag<A>? {
guard let quelle = datei(schluessel),
let daten = try? Data(contentsOf: quelle),
let wert = try? JSONDecoder().decode(A.self, from: daten) else { return nil }
let wann = (try? quelle.resourceValues(forKeys: [.contentModificationDateKey]))?
.contentModificationDate ?? Date()
return Eintrag(wert: wert, geholtAm: wann)
}
static func leere() {
guard let ordner else { return }
try? FileManager.default.removeItem(at: ordner)
}
}
/// Was eine Ansicht gerade anzeigen soll.
///
/// Der vierte Fall ist der Grund für diesen Typ: Daten da, aber vom Band –
/// die Ansicht muss das sagen können, ohne dass jede Ansicht ihre eigene
/// Zustandsverwaltung erfindet.
enum Ladezustand<A: Sendable>: Sendable {
case laedt
case da(A)
/// Der Abruf schlug fehl, aber wir haben noch einen Stand von vorher.
case ausSpeicher(A, geholtAm: Date, fehler: APIFehler)
case fehler(APIFehler)
var inhalt: A? {
switch self {
case .da(let wert): return wert
case .ausSpeicher(let wert, _, _): return wert
case .laedt, .fehler: return nil
}
}
var laeuft: Bool {
if case .laedt = self { return true }
return false
}
}
+145
View File
@@ -0,0 +1,145 @@
# Certain für iOS
Native App zum Certain-Backend.
> **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 das Projekt.
## Bauen und starten
Einfach `Certain.xcodeproj` öffnen — es liegt mit im Repository.
Die Wahrheit ist aber `project.yml`: eine XcodeGen-Spezifikation, aus der das
Projekt entsteht. **Wer eine Swift-Datei anlegt, läuft danach einmal
`xcodegen generate`**, sonst kennt Xcode sie nicht. Ein neuer *Ordner* will
ausserdem eine Zeile unter `sources:`.
```
brew install xcodegen # einmalig
cd ios && xcodegen generate
xcodebuild -project Certain.xcodeproj -scheme Certain \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
xcodebuild -project Certain.xcodeproj -scheme Certain \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' test
```
Festgelegt sind dort: Bündelkennung `com.scarriffle.certain`, Team
`PP34X97WS3`, iPhone und iPad, Grundsprache Deutsch, **iOS 26** als
Mindestversion, Sprachfassung Swift 5. Wer mit einem anderen Apple-Konto baut,
trägt seine Team-ID in `project.yml` ein.
**Nur https.** App Transport Security steht auf der Voreinstellung, ein Server
unter `http://…` wird also abgewiesen — auch `http://localhost` beim
Entwickeln. Das ist Absicht: über die Verbindung geht die Gerätemarke, ein
Vollzugang. Wer eine Ausnahme braucht, trägt sie bewusst in `project.yml` ein.
## Was wohin gehört
| Ordner | Inhalt |
|---|---|
| `App/` | Einstiegspunkt, Navigation, globaler Zustand |
| `Netzwerk/` | Client, Endpunkte, Fehler, Offline-Ablage |
| `Modelle/` | Codable-Typen, eins zu eins zu den Backend-Schemas |
| `Bausteine/` | Wiederverwendetes: Geld, Farben, Symbole, Zustandsansichten, Formularfelder |
| `Anmeldung/` | Server-Adresse, Anmeldung, zweiter Faktor, Passwortwechsel |
| `Dashboard/` | Die Übersicht: vier feste Karten |
| `Budget/` | Soll gegen Ist, Monat für Monat |
| `Buchungen/` | Buchungsliste mit Suche, Formular zum Anlegen und Ändern |
| `Abos/` | Verträge und Abos |
| `Arbeitsliste/` | Eingang, Übertragsvorschläge, Dubletten |
| `Nebenraeume/` | Kategorien, Schlagwörter, Bank, zweiter Faktor |
| `Konten/` | Kontenliste und Konto-Formular |
| `Texte/` | Übersetzungen, Deutsch und Englisch zeilengleich |
| `Tests/` | Logiktests ohne Netz, plus die Modellprüfung gegen `Fixtures/antworten.json` |
## Wie die Anmeldung funktioniert
Das Backend kennt für die Weboberfläche nur ein Sitzungs-Cookie. Für die App
wurde eine **Gerätemarke der Reichweite `app`** ergänzt
(`backend/app/models.py`, `TokenScope`). Der Ablauf:
1. Nutzer gibt die Server-Adresse ein. Die App prüft mit `GET /api/version`, ob
dort überhaupt ein Certain läuft.
2. Nutzer meldet sich mit Benutzername und Passwort an — über das normale
Cookie, inklusive zweitem Faktor und Pflicht-Passwortwechsel, falls fällig.
3. Noch in derselben Sitzung legt die App über `POST /api/inbox/tokens` mit
`scope: "app"` eine Marke an und legt deren Klartext im **Schlüsselbund** ab.
4. Danach läuft **alles** über den Header `X-Certain-Token`. Das Cookie wird
verworfen.
Damit gibt es kein Passwort auf dem Gerät und keinen Ablauf nach 30 Tagen. Die
Marke lässt sich am Rechner unter Einstellungen → Gerätemarken einzeln
widerrufen; danach sperrt sie sofort.
Eine App-Marke kann **keine weitere Marke ausstellen** — das geht nur mit einer
echten Anmeldung. Sonst hätte ein Dieb sich Ersatzschlüssel ausgestellt und das
Widerrufen wäre wirkungslos.
## Später: Erweiterungen
Teilen-Menü und Kurzbefehle kommen in Stufe 6. Beide brauchen Zugriff auf
dieselbe Marke wie die App. Dafür ist im Projekt nötig:
- eine **App-Gruppe** (`group.<deine-bundle-id>`) für die Server-Adresse,
- eine **geteilte Schlüsselbund-Gruppe** für die Marke selbst.
`Netzwerk/Ablage.swift` liest beides bereits aus Konstanten, die du an einer
Stelle setzt (`Ablage.gruppe`). Solange keine Erweiterung existiert, funktioniert
alles auch ohne App-Gruppe.
## Was die App bewusst nicht kann
- **Bankkonten verbinden.** Die Bank leitet auf eine feste Web-Adresse zurück,
die bei Enable Banking registriert ist. Abrufen und Zuordnen geht nativ, das
Herstellen der Verbindung bleibt im Browser.
- **Postfach einrichten** und **Sicherung/Wiederherstellung.** Gehört an den
Rechner.
- **Benutzerverwaltung.** Administration für Mehrbenutzerbetrieb.
## Was geprüft ist — und was nicht
**Geprüft:** Jedes Feld der Modelle wurde gegen echte Antworten eines
laufenden Backends verglichen (`/api/version`, `/api/auth/me`, `/api/accounts`,
`/api/settings`, `/api/inbox/tokens`). Alle Namen stimmen überein. Dabei kam
heraus, dass `created_at` ohne Zeitzone und mit Mikrosekunden kommt
(`2026-10-20T14:18:15.780705`) — dafür hat `Zeitpunkt` einen dritten Leser,
und `GrundlagenTests` deckt den Fall ab.
**Ebenfalls geprüft:** Dass es übersetzt. Ein einziger Fehler in 2600 blind
geschriebenen Zeilen (`[:]` statt `[]` in `CertainClient.schickeDatei`), danach
keine Warnung im eigenen Code, 28 Tests grün, Start im Simulator bis zur
Serverabfrage.
**Nicht geprüft:** Der Weg gegen ein echtes Backend — anmelden, zweiter Faktor,
Marke anlegen, Konten laden.
### Wo es beim ersten Bauen am ehesten klemmte
Nichts davon trat ein; die Liste bleibt als Landkarte der heiklen Stellen.
1. **`@testable import Certain`** in `Tests/GrundlagenTests.swift` — muss auf
den Namen deines App-Ziels zeigen. Heisst es anders, anpassen.
2. **`Lader` ist eine generische `@Observable`-Klasse.** Das geht, ist aber
die ungewöhnlichste Konstruktion hier. Falls der Übersetzer murrt: Die
Klasse wird bisher nur an einer Stelle benutzt (`KontenListe`).
3. **Nebenläufigkeit.** `AppZustand` ist `@MainActor`, `CertainClient` ein
Actor. Unter strikter Nebenläufigkeitsprüfung kann es an den Übergängen
Warnungen setzen.
4. **`Tab` und `.tabViewStyle(.sidebarAdaptable)`** in `RootAnsicht` setzen
iOS 18 voraus. Bei iOS 26 als Mindestversion kein Problem.
5. **Die Tests nutzen Swift Testing** (`import Testing`, `@Test`, `#expect`),
nicht XCTest. Xcode 16 und neuer kann beides im selben Ziel.
Schick mir die Fehler, dann räumen wir sie weg.
## Stand
Stufe 1: Gerüst, Anmeldung samt zweitem Faktor und Passwortwechsel,
Kontenliste. 21 Swift-Dateien, rund 2600 Zeilen. Siehe den Plan im Repository
für die weiteren Stufen.
Noch nicht in der App, obwohl in Stufe 1 erreichbar: Einen zweiten Faktor
**einrichten** geht nur im Browser. Wer ein Konto hat, für das die Richtlinie
MFA verlangt, sieht deshalb einen Hinweis mit Link statt eines Einrichtungs-
Assistenten. Das kommt mit den Einstellungen in Stufe 5.
File diff suppressed because it is too large Load Diff
+820
View File
@@ -0,0 +1,820 @@
import Testing
import Foundation
@testable import Certain
// Reine Logik, kein Netz. Das ist alles, was sich ohne laufenden Server
// prüfen lässt – und zugleich das, was beim blinden Schreiben am ehesten
// still danebengeht.
//
// Hinweis: `@testable import Certain` muss auf den Namen deines App-Ziels
// zeigen. Heisst es anders, hier anpassen.
// MARK: - Geldbeträge
@Suite("Geldbetrag")
struct GeldbetragTests {
@Test("Komma und Punkt meinen dasselbe")
func trennerSindAustauschbar() {
#expect(Geldbetrag.ausEingabe("12,50", waehrung: "CHF")?.cents == 1250)
#expect(Geldbetrag.ausEingabe("12.50", waehrung: "CHF")?.cents == 1250)
#expect(Geldbetrag.ausEingabe("12", waehrung: "CHF")?.cents == 1200)
#expect(Geldbetrag.ausEingabe("0,05", waehrung: "CHF")?.cents == 5)
}
@Test("Bei beiden Zeichen ist das hintere der Dezimaltrenner")
func tausenderUndDezimal() {
#expect(Geldbetrag.ausEingabe("1.234,50", waehrung: "EUR")?.cents == 123450)
#expect(Geldbetrag.ausEingabe("1,234.50", waehrung: "EUR")?.cents == 123450)
}
@Test("Drei Stellen hinter dem Trenner sind Tausender, keine Rappen")
func dreiStellenSindKeinBruch() {
// "1.234" meint tausendzweihundertvierunddreissig, nicht 1,23.
#expect(Geldbetrag.ausEingabe("1.234", waehrung: "EUR")?.cents == 123400)
}
@Test("Vorzeichen bleibt erhalten")
func negativ() {
#expect(Geldbetrag.ausEingabe("-5", waehrung: "EUR")?.cents == -500)
#expect(Geldbetrag.ausEingabe("-12,34", waehrung: "EUR")?.cents == -1234)
}
@Test("Unsinn ergibt keinen Betrag")
func unsinn() {
#expect(Geldbetrag.ausEingabe("", waehrung: "EUR") == nil)
#expect(Geldbetrag.ausEingabe(" ", waehrung: "EUR") == nil)
// Der wichtige Fall: Ohne diese Prüfung käme hier 0,00 heraus und die
// App buchte stillschweigend einen Nullbetrag.
#expect(Geldbetrag.ausEingabe("abc", waehrung: "EUR") == nil)
#expect(Geldbetrag.ausEingabe(",", waehrung: "EUR") == nil)
}
@Test("Hin und zurück durchs Eingabefeld")
func rundlauf() {
let betrag = Geldbetrag(cents: 123450, waehrung: "CHF")
#expect(betrag.alsEingabe == "1234,50")
#expect(Geldbetrag.ausEingabe(betrag.alsEingabe, waehrung: "CHF")?.cents == 123450)
let krumm = Geldbetrag(cents: 5, waehrung: "CHF")
#expect(krumm.alsEingabe == "0,05")
#expect(Geldbetrag.ausEingabe(krumm.alsEingabe, waehrung: "CHF")?.cents == 5)
}
@Test("Die Formatierung nennt Betrag und Währung")
func formatierung() {
let text = Geldbetrag(cents: 1250, waehrung: "CHF").formatiert()
// Kein Vergleich auf die genaue Zeichenkette: Trennzeichen und Stellung
// des Währungszeichens hängen an der Sprache des Geräts.
#expect(text.contains("12"))
#expect(text.contains("50"))
}
@Test("Plus nur, wenn ausdrücklich gewünscht")
func vorzeichenstil() {
let positiv = Geldbetrag(cents: 500, waehrung: "EUR")
#expect(positiv.formatiert(vorzeichen: .immer).hasPrefix("+"))
#expect(positiv.formatiert().hasPrefix("+") == false)
}
@Test("Summieren geht nur in derselben Währung")
func summieren() {
let a = Geldbetrag(cents: 100, waehrung: "EUR")
let b = Geldbetrag(cents: 250, waehrung: "EUR")
#expect((a + b).cents == 350)
}
}
// MARK: - Zeitangaben
@Suite("Zeitangaben")
struct ZeitangabenTests {
@Test("Ein Tagesdatum überlebt Hin- und Rückweg")
func tagesdatum() throws {
let tag = try #require(Tagesdatum("2026-07-14"))
#expect(tag.text == "2026-07-14")
#expect(Tagesdatum(tag.datum).text == "2026-07-14")
}
@Test("Tage lassen sich vergleichen")
func reihenfolge() throws {
let frueher = try #require(Tagesdatum("2026-01-31"))
let spaeter = try #require(Tagesdatum("2026-02-01"))
#expect(frueher < spaeter)
}
@Test("Kaputte Tagesangaben ergeben nichts")
func kaputterTag() {
#expect(Tagesdatum("14.07.2026") == nil)
#expect(Tagesdatum("") == nil)
#expect(Tagesdatum("2026-13-45") == nil)
}
@Test("Zeitstempel mit und ohne Sekundenbruchteile")
func zeitpunkte() {
// Das Backend schickt beide Formen, je nach Feld.
#expect(Zeitpunkt("2026-07-14T10:11:12Z") != nil)
#expect(Zeitpunkt("2026-07-14T10:11:12.345Z") != nil)
#expect(Zeitpunkt("2026-07-14T10:11:12+02:00") != nil)
// Python schreibt ohne Zone, wenn keine gesetzt ist.
#expect(Zeitpunkt("2026-07-14T10:11:12") != nil)
// Mikrosekunden, wie SQLite sie ablegt.
#expect(Zeitpunkt("2026-07-14T10:11:12.345678") != nil)
#expect(Zeitpunkt("übermorgen") == nil)
}
}
// MARK: - Server-Adresse
@Suite("Server-Adresse")
struct AdressTests {
@Test("Ohne Schema wird https ergänzt")
func schemaErgaenzen() throws {
let adresse = try #require(AppZustand.deuteAdresse("budget.beispiel.com"))
#expect(adresse.absoluteString == "https://budget.beispiel.com")
}
@Test("Ein angehängter Pfad fliegt raus")
func pfadWeg() throws {
// Der Nutzer kopiert gern die Adresse aus der Browserzeile, samt Pfad.
// Die API hängt aber immer unter /api an der Wurzel.
let adresse = try #require(AppZustand.deuteAdresse("https://budget.beispiel.com/buchungen?x=1"))
#expect(adresse.absoluteString == "https://budget.beispiel.com")
}
@Test("http bleibt http")
func schemaBleibt() throws {
let adresse = try #require(AppZustand.deuteAdresse("http://192.168.1.5:8000"))
#expect(adresse.absoluteString == "http://192.168.1.5:8000")
}
@Test("Unsinn ergibt keine Adresse")
func unsinn() {
#expect(AppZustand.deuteAdresse("") == nil)
#expect(AppZustand.deuteAdresse(" ") == nil)
}
}
// MARK: - Symbole
@Suite("Symbole")
struct SymbolTests {
@Test("Bekannte Namen werden übersetzt")
func bekannt() {
#expect(Symbole.sf("wallet") == "wallet.bifold")
#expect(Symbole.sf("shopping-cart") == "cart")
}
@Test("Unbekanntes wird zum Platzhalter, nie zu nichts")
func unbekannt() {
// Eine leere Stelle in der Liste sähe aus wie ein Fehler.
#expect(Symbole.sf("gibtesnicht") == Symbole.platzhalter)
#expect(Symbole.sf(nil) == Symbole.platzhalter)
}
}
// MARK: - Fehlerantworten
@Suite("Fehlerantworten")
struct FehlerTests {
@Test("detail als Zeichenkette")
func alsText() {
let daten = Data(#"{"detail":"Nicht gefunden"}"#.utf8)
#expect(Fehlerantwort.ausDaten(daten) == "Nicht gefunden")
}
@Test("detail als Liste, wie bei 422")
func alsListe() {
// Genau hier gehen Meldungen verloren, wenn der Decoder nur eine
// Zeichenkette kennt.
let daten = Data(#"{"detail":[{"loc":["body","amount_cents"],"msg":"muss groesser als 0 sein","type":"value_error"}]}"#.utf8)
#expect(Fehlerantwort.ausDaten(daten) == "muss groesser als 0 sein")
}
@Test("Was gar kein Fehlerkörper ist, ergibt leeren Text statt eines Absturzes")
func unbrauchbar() {
#expect(Fehlerantwort.ausDaten(Data("<html>".utf8)) == "")
#expect(Fehlerantwort.ausDaten(Data()) == "")
}
}
// MARK: - Modelle gegen echte Antworten
@Suite("Modelle")
struct ModellTests {
@Test("Ein Konto, wie der Server es schickt")
func konto() throws {
// Abgenommen von einer echten Antwort aus GET /api/accounts.
let roh = """
{"id":3,"name":"Revolut CHF","type":"girokonto","currency":"CHF",
"icon":"wallet","color":"#3f51b5","opening_balance_cents":0,
"opening_date":"2026-01-01","credit_limit_cents":null,
"is_locked":false,"is_archived":false,"position":2,
"savings_category_id":null,
"identifiers":[{"id":7,"kind":"iban","value":"CH93..."}],
"balance_cents":-1043,"balance_base_cents":-1043,
"bank_balance_cents":null,"bank_balance_at":null,
"bank_balance_vergleichbar":false}
"""
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
#expect(konto.name == "Revolut CHF")
#expect(konto.type == .girokonto)
#expect(konto.saldo.cents == -1043)
#expect(konto.identifiers.first?.kind == .iban)
#expect(konto.weichtVonDerBankAb == false)
}
@Test("Ein Banksaldo, der abweicht, wird gemeldet")
func saldoWeichtAb() throws {
let roh = """
{"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet",
"color":"#6b7480","opening_balance_cents":0,"opening_date":null,
"credit_limit_cents":null,"is_locked":false,"is_archived":false,
"position":0,"savings_category_id":null,"identifiers":[],
"balance_cents":1000,"balance_base_cents":1000,
"bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z",
"bank_balance_vergleichbar":true}
"""
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
#expect(konto.weichtVonDerBankAb)
}
@Test("Ein nicht vergleichbarer Banksaldo loest keinen Hinweis aus")
func saldoNichtVergleichbar() throws {
// Ein verfuegbarer Saldo enthaelt Vorgemerktes - dass er abweicht,
// ist normal und keine Meldung wert.
let roh = """
{"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet",
"color":"#6b7480","opening_balance_cents":0,"opening_date":null,
"credit_limit_cents":null,"is_locked":false,"is_archived":false,
"position":0,"savings_category_id":null,"identifiers":[],
"balance_cents":1000,"balance_base_cents":1000,
"bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z",
"bank_balance_vergleichbar":false}
"""
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
#expect(konto.weichtVonDerBankAb == false)
}
@Test("Einstellungen mit Lücken fallen auf sinnvolle Werte zurück")
func einstellungen() {
let leer = Einstellungen([])
#expect(leer.basiswaehrung == "EUR")
#expect(leer.budgetStarttag == 1)
let gesetzt = Einstellungen([
Einstellung(key: "base_currency", value: "CHF"),
Einstellung(key: "budget_start_day", value: "25"),
Einstellung(key: "active_currencies", value: "EUR, CHF ,USD"),
])
#expect(gesetzt.basiswaehrung == "CHF")
#expect(gesetzt.budgetStarttag == 25)
#expect(gesetzt.aktiveWaehrungen == ["EUR", "CHF", "USD"])
}
@Test("Ein unsinniger Starttag wird eingefangen")
func starttagGrenzen() {
// Das Backend begrenzt auf 1..31; die App darf sich nicht darauf
// verlassen, dass in der Einstellung nichts anderes steht.
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "99")])
.budgetStarttag == 31)
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "0")])
.budgetStarttag == 1)
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "weiss nicht")])
.budgetStarttag == 1)
}
@Test("Die Serverkennung erkennt einen fremden Server")
func serverkennung() throws {
let echt = try JSONDecoder().decode(
Serverkennung.self,
from: Data(#"{"name":"Certain Finance","version":"0.1.0","git_sha":"abc"}"#.utf8))
#expect(echt.istCertain)
let fremd = try JSONDecoder().decode(
Serverkennung.self,
from: Data(#"{"name":"Nextcloud","version":"29","git_sha":null}"#.utf8))
#expect(fremd.istCertain == false)
}
}
// MARK: - Überträge zusammenfassen
@Suite("Übertragszeilen")
struct UebertragszeilenTests {
/// Eine Buchung, wie der Server sie schickt – nur die Felder, auf die es
/// hier ankommt.
private func buchung(_ id: Int, konto: Int, cents: Int,
art: Buchungsart = .expense) throws -> Buchung {
let roh = """
{"id": \(id), "type": "\(art.rawValue)", "date": "2026-10-15", "time": null,
"account_id": \(konto), "to_account_id": null, "account_currency": "EUR",
"amount_cents": \(cents), "to_amount_cents": null, "fx_rate": null,
"amount_eur_cents": \(cents), "amount_base_cents": \(cents),
"category_id": null, "budget_treatment": null, "budget_category_id": null,
"subscription_id": null, "budget_period": "2026-10",
"budget_period_manuell": false, "import_batch_id": null,
"herkunft": "bank", "payee": "Test", "description": null, "notes": null,
"tag_ids": []}
"""
return try JSONDecoder().decode(Buchung.self, from: Data(roh.utf8))
}
private func verknuepfung(_ anderes: Int, raus: Bool, gegenkonto: Int?) -> Uebertragsverknuepfung {
Uebertragsverknuepfung(linkId: 1, otherId: anderes,
richtung: raus ? "raus" : "rein",
otherAccountId: gegenkonto, automatisch: true)
}
@Test("Zwei verknüpfte Buchungen ergeben eine Zeile, nicht zwei")
func paarWirdZusammengefasst() throws {
let raus = try buchung(1, konto: 10, cents: -5000)
let rein = try buchung(2, konto: 20, cents: 5000, art: .income)
let zeilen = Buchungszeile.zeilen(aus: [raus, rein], paare: [
"1": verknuepfung(2, raus: true, gegenkonto: 20),
"2": verknuepfung(1, raus: false, gegenkonto: 10),
])
#expect(zeilen.count == 1)
#expect(zeilen[0].istUebertrag)
#expect(zeilen[0].vonKonto == 10)
#expect(zeilen[0].nachKonto == 20)
}
@Test("Fehlt das zweite Bein, verschwindet die Zeile nicht")
func einzelnesBeinBleibtStehen() throws {
// Genau der Fall eines Kontofilters: Die Gegenbuchung liegt auf einem
// Konto, das gerade nicht gezeigt wird.
let raus = try buchung(1, konto: 10, cents: -5000)
let zeilen = Buchungszeile.zeilen(aus: [raus], paare: [
"1": verknuepfung(2, raus: true, gegenkonto: 20),
])
#expect(zeilen.count == 1)
#expect(zeilen[0].istUebertrag)
// Das Gegenkonto steht in der Verknüpfung, auch ohne die Buchung.
#expect(zeilen[0].nachKonto == 20)
}
@Test("Die Richtung kommt aus der Verknüpfung, nicht aus dem Vorzeichen")
func richtungZaehlt() throws {
// Manche Banken liefern beide Beine positiv. Wer am Vorzeichen
// ablesen wollte, wohin das Geld geht, läge hier falsch herum.
let rein = try buchung(2, konto: 20, cents: 5000, art: .income)
let zeilen = Buchungszeile.zeilen(aus: [rein], paare: [
"2": verknuepfung(1, raus: false, gegenkonto: 10),
])
#expect(zeilen[0].vonKonto == 10)
#expect(zeilen[0].nachKonto == 20)
}
@Test("Ohne Verknüpfung bleibt jede Buchung ihre eigene Zeile")
func ohneVerknuepfung() throws {
let eins = try buchung(1, konto: 10, cents: -1000)
let zwei = try buchung(2, konto: 10, cents: -2000)
let zeilen = Buchungszeile.zeilen(aus: [eins, zwei], paare: [:])
#expect(zeilen.count == 2)
#expect(zeilen.allSatisfy { !$0.istUebertrag })
}
}
// MARK: - Was der Client selbst rechnet
@Suite("Budget-Anteile")
struct BudgetAnteilTests {
private func kategorie(geplant: Int, ist: Int) throws -> Budgetkategorie {
let roh = """
{"category_id": 1, "name": "Test", "icon": "home", "color": "#3f51b5",
"planned_cents": \(geplant), "actual_cents": \(ist),
"delta_cents": \(geplant - ist), "over": \(ist > geplant),
"modus": "fest", "zuschlag_cents": 0, "aus_vertraegen_cents": 0,
"vertraege_anzahl": 0, "good": false}
"""
return try JSONDecoder().decode(Budgetkategorie.self, from: Data(roh.utf8))
}
@Test("Ohne Plan gibt es keinen Anteil, keinen vollen Balken")
func ohnePlanKeinBalken() throws {
#expect(try kategorie(geplant: 0, ist: 0).anteilVerbraucht == 0)
// Ausgegeben ohne Plan: Ein voller Balken sähe aus wie "genau
// aufgebraucht" und wäre die falsche Auskunft.
#expect(try kategorie(geplant: 0, ist: 5000).anteilVerbraucht == 0)
}
@Test("Überzogen wird bei eins gekappt")
func gekappt() throws {
#expect(try kategorie(geplant: 10000, ist: 25000).anteilVerbraucht == 1)
#expect(try kategorie(geplant: 10000, ist: 5000).anteilVerbraucht == 0.5)
}
}
// MARK: - Nachschlagewerk
@Suite("Nachschlag")
struct NachschlagTests {
private func gruppe() throws -> Kategoriegruppe {
let roh = """
{"id": 1, "name": "Fixkosten", "kind": "expense", "icon": "landmark",
"color": "#3f51b5", "position": 0, "is_builtin": true,
"categories": [{"id": 7, "group_id": 1, "name": "Wohnen", "type": "expense",
"icon": "home", "color": "#3f51b5", "position": 0, "is_builtin": true,
"is_archived": false, "excluded_from_stats": false,
"spending_is_good": false, "keyword_count": 0}]}
"""
return try JSONDecoder().decode(Kategoriegruppe.self, from: Data(roh.utf8))
}
@Test("Eine ID findet ihren Namen")
func kategorieNachId() throws {
let nachschlag = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: [])
#expect(nachschlag.kategorie(7)?.name == "Wohnen")
#expect(nachschlag.kategorie(999) == nil)
#expect(nachschlag.kategorie(nil) == nil)
}
@Test("Ein unbekanntes Konto ergibt Text, nicht nichts")
func unbekanntesKonto() throws {
// Eine leere Zeile in der Liste wäre schlimmer als das Wort
// "Unbekannt" – sie sähe aus wie ein Fehler der App.
#expect(!Nachschlag.leer.kontoname(42).isEmpty)
}
@Test("Der Zwischenspeicher gibt dasselbe Nachschlagewerk zurück")
func ueberlebtDenSpeicher() throws {
let original = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: [])
let roh = try JSONEncoder().encode(original)
let zurueck = try JSONDecoder().decode(Nachschlag.self, from: roh)
// Die Nachschlagetabellen werden beim Lesen neu gebaut, nicht
// mitgeschrieben – genau das prüft dieser Test.
#expect(zurueck.kategorie(7)?.name == "Wohnen")
}
}
// MARK: - Die Modelle gegen echte Serverantworten
/// Nur da, um über `Bundle(for:)` an die Prüfdatei zu kommen. Swift Testing
/// kennt keine Testklasse, an der sich das Bündel ablesen liesse.
private final class Markierung {}
/// Entschlüsselt jedes Modell aus einer echten Antwort des Backends.
///
/// Das ist die Prüfung, die beim blinden Schreiben wirklich etwas fängt: Ein
/// vertippter Feldname übersetzt anstandslos und fällt erst auf, wenn eine
/// Liste beim Nutzer leer bleibt. `Tests/Fixtures/antworten.json` kommt aus
/// `backend/scripts/erzeuge_ios_fixture.py`; nach jeder Schema-Änderung neu
/// erzeugen.
@Suite("Serverantworten")
struct ServerantwortenTests {
private static let antworten: [String: Data] = {
guard let ort = Bundle(for: Markierung.self)
.url(forResource: "antworten", withExtension: "json"),
let roh = try? Data(contentsOf: ort),
let oben = try? JSONSerialization.jsonObject(with: roh) as? [String: Any]
else { return [:] }
return oben.compactMapValues {
try? JSONSerialization.data(withJSONObject: $0, options: .fragmentsAllowed)
}
}()
private func lies<A: Decodable>(_ typ: A.Type, _ name: String) throws -> A {
let daten = try #require(Self.antworten[name],
"Prüfdatei kennt '\(name)' nicht – neu erzeugen?")
return try JSONDecoder().decode(A.self, from: daten)
}
@Test("Die Prüfdatei liegt im Testbündel")
func dateiIstDa() {
// Ohne diesen Test wären alle folgenden still übersprungen, wenn die
// Datei nicht ins Ziel kopiert wird.
#expect(!Self.antworten.isEmpty)
}
@Test("Konten, Kategorien und Schlagwörter")
func stammdaten() throws {
let konten = try lies([Konto].self, "konten")
// Nicht auf eine genaue Zahl pruefen: Die Pruefdatei waechst mit
// jeder Stufe, und ein Test, der dann rot wird, sagt nichts aus.
#expect(konten.count >= 2)
#expect(konten.contains { $0.isLocked })
let gruppen = try lies([Kategoriegruppe].self, "kategoriegruppen")
#expect(gruppen.contains { !$0.categories.isEmpty })
#expect(try !lies([Schlagwort].self, "schlagwoerter").isEmpty)
}
@Test("Buchungen samt Tags, Übertrag und Vertragszuordnung")
func buchungen() throws {
let alle = try lies([Buchung].self, "buchungen")
#expect(alle.count >= 5)
// Jede Spielart einmal – sonst prüft die Datei die Felder nicht, die
// nur in einem Fall belegt sind.
#expect(alle.contains { !$0.tagIds.isEmpty })
#expect(alle.contains { $0.type == .transfer && $0.toAccountId != nil })
#expect(alle.contains { $0.subscriptionId != nil })
#expect(alle.contains { $0.notes?.isEmpty == false })
}
@Test("Die Paarliste fasst zwei getrennte Buchungen zusammen")
func verknuepfungen() throws {
let paare = try lies([String: Uebertragsverknuepfung].self, "uebertragsverknuepfungen")
#expect(paare.count == 2)
let alle = try lies([Buchung].self, "buchungen")
let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare)
// Die Paarliste fuehrt jede Verknuepfung zweimal auf, einmal je
// Richtung - also halb so viele Paare wie Eintraege. Genau so viele
// Zeilen fallen beim Zusammenfassen weg.
#expect(zeilen.count == alle.count - paare.count / 2)
let uebertraege = zeilen.filter(\.istUebertrag)
#expect(!uebertraege.isEmpty)
}
@Test("Budget-Monat, Vorlage und Verträge")
func budget() throws {
let monat = try lies(Budgetmonat.self, "budget")
#expect(!monat.groups.isEmpty)
#expect(monat.groups.contains { !$0.categories.isEmpty })
#expect(monat.totals.plannedExpense > 0)
// prev/next tragen die Blätterung – ohne sie steht die Ansicht still.
#expect(!monat.prev.isEmpty && !monat.next.isEmpty)
let vorlage = try lies(Budgetvorlage.self, "budgetvorlage")
// Betriebsart und Zuschlag kamen mit Backend-Ergaenzung C dazu.
#expect(vorlage.lines.contains { $0.modus == "fest" })
let abos = try lies(Aboliste.self, "abos")
#expect(abos.summary.anzahlAktiv >= 1)
#expect(abos.subscriptions.contains { $0.intervalMonths == 12 })
}
@Test("Alle vier Dashboard-Karten")
func dashboard() throws {
let staende = try lies(Kontostaende.self, "kontostaende")
#expect(staende.accounts.count >= 2)
#expect(staende.gebunden != 0)
#expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty)
#expect(try lies(EinnahmenAusgaben.self, "einnahmenAusgaben").income > 0)
let letzte = try lies(LetzteBuchungen.self, "letzteBuchungen")
#expect(!letzte.transactions.isEmpty)
// Ein Übertrag hat keine Richtung – sign ist dort null.
#expect(letzte.transactions.contains { $0.vorzeichen == .keines })
}
@Test("Benutzer, Einstellungen und Serverkennung")
func zugang() throws {
#expect(try lies(Serverkennung.self, "version").istCertain)
#expect(try !lies(Benutzer.self, "ichSelbst").username.isEmpty)
let roh = try lies([Einstellung].self, "einstellungen")
#expect(!Einstellungen(roh).basiswaehrung.isEmpty)
}
}
// MARK: - Was zum Server hinausgeht
@Suite("Eingaben")
struct EingabenTests {
/// Die Felder, die wirklich im JSON stehen.
private func felder<A: Encodable>(_ wert: A) throws -> [String: Any] {
let roh = try JSONEncoder().encode(wert)
return try #require(JSONSerialization.jsonObject(with: roh) as? [String: Any])
}
@Test("Eine Teiländerung schickt nur, was gesetzt ist")
func teilaenderungBleibtTeilweise() throws {
// Der Fall, der stillschweigend Daten frisst: Ginge ein nicht
// angefasstes Feld als null hinaus, loeschte jedes Speichern den
// Empfänger, die Notiz und die Kategorie gleich mit.
let nurNotiz = try felder(BuchungAendern(notes: "Kassenbon fehlt"))
#expect(nurNotiz.keys.sorted() == ["notes"])
#expect(nurNotiz["notes"] as? String == "Kassenbon fehlt")
}
@Test("Eine leere Tag-Liste geht mit, denn sie bedeutet etwas")
func leereTagListeGehtMit() throws {
// Mitgeschickt heisst ERSETZEN. Eine leere Liste nimmt alle Tags ab;
// sie wegzulassen liesse sie stehen. Die zwei Faelle duerfen nicht
// zusammenfallen.
let ohneTags = try felder(BuchungAendern(tagIds: []))
#expect(ohneTags["tag_ids"] as? [Int] == [])
let unberuehrt = try felder(BuchungAendern(payee: "Laden"))
#expect(unberuehrt["tag_ids"] == nil)
}
@Test("Die Feldnamen sind die des Backends")
func feldnamenPassen() throws {
let anlegen = try felder(BuchungAnlegen(
type: .expense, date: #require(Tagesdatum("2026-10-15")), accountId: 3,
amountCents: 4250, categoryId: 7, payee: "Supermarkt"))
#expect(anlegen["account_id"] as? Int == 3)
#expect(anlegen["amount_cents"] as? Int == 4250)
#expect(anlegen["category_id"] as? Int == 7)
#expect(anlegen["date"] as? String == "2026-10-15")
#expect(anlegen["type"] as? String == "expense")
}
@Test("Konto und Vertrag ändern sich ebenso nur in dem, was gesetzt ist")
func weitereTeilaenderungen() throws {
#expect(try felder(KontoAendern(isArchived: true)).keys.sorted() == ["is_archived"])
#expect(try felder(AboAendern(isActive: false)).keys.sorted() == ["is_active"])
let kreditrahmen = try felder(KontoAendern(creditLimitCents: 200000))
#expect(kreditrahmen["credit_limit_cents"] as? Int == 200000)
}
@Test("Die Rhythmen sind genau die, die das Backend annimmt")
func rhythmenPassenZumBackend() {
// INTERVALL_MONATE in backend/app/schemas.py. Weicht die App ab,
// antwortet der Server mit 422 auf etwas, das die Auswahl anbot.
#expect(Zahlungsrhythmus.allCases.map(\.rawValue) == [1, 2, 3, 6, 12])
}
}
// MARK: - Arbeitsliste
@Suite("Arbeitsliste")
struct ArbeitslisteTests {
private func eintrag(stand: String, konto: Int?, kategorie: Int?,
gegenkonto: Int? = nil) throws -> Eingangseintrag {
let roh = """
{"id": 1, "source": "shortcut", "status": "\(stand)",
"account_id": \(konto.map(String.init) ?? "null"),
"date": "2026-10-15", "amount_cents": -1290, "currency": "EUR",
"payee": "Baeckerei", "note": null,
"category_id": \(kategorie.map(String.init) ?? "null"),
"transaction_id": null,
"transfer_account_id": \(gegenkonto.map(String.init) ?? "null"),
"fx_rate": null, "counter_amount_cents": null, "counter_currency": null,
"created_at": "2026-10-15T14:18:15.780705"}
"""
return try JSONDecoder().decode(Eingangseintrag.self, from: Data(roh.utf8))
}
@Test("Ohne Konto laesst sich nichts buchen")
func ohneKontoKeinBuchen() throws {
// Das Backend lehnt es mit 400 ab. Den Knopf trotzdem anzubieten
// hiesse, dem Nutzer eine Fehlermeldung als Antwort zu geben.
#expect(try eintrag(stand: "pending", konto: nil, kategorie: nil)
.laesstSichBuchen == false)
#expect(try eintrag(stand: "pending", konto: 3, kategorie: nil)
.laesstSichBuchen)
}
@Test("Was schon gebucht ist, steht nicht mehr zum Buchen an")
func gebuchtesIstErledigt() throws {
#expect(try eintrag(stand: "booked", konto: 3, kategorie: 7)
.laesstSichBuchen == false)
// Unklares schon: Es wartet auf einen Blick, nicht auf ein Konto.
#expect(try eintrag(stand: "unclear", konto: 3, kategorie: nil)
.laesstSichBuchen)
}
@Test("Ein Uebertrag braucht keine Kategorie")
func uebertragBrauchtKeineKategorie() throws {
// Ohne transfer_account_id liesse sich ein Uebertrag nicht von einer
// Ausgabe unterscheiden - und die App fragte ewig nach einer
// Kategorie, die es nie geben wird.
#expect(try eintrag(stand: "booked", konto: 3, kategorie: nil)
.brauchtKategorie)
#expect(try eintrag(stand: "booked", konto: 3, kategorie: nil, gegenkonto: 4)
.brauchtKategorie == false)
}
@Test("Ein Vorschlag ist dasselbe Paar, egal herum")
func paarIstUngeordnet() throws {
let roh = """
{"verknuepft": 0, "vorschlaege": [
{"a": {"id": 9, "date": "2026-10-15", "type": "expense",
"amount_cents": 7500, "currency": "EUR", "account_id": 1,
"account_name": "Giro", "payee": "Dauerauftrag",
"description": null, "category_id": null},
"b": {"id": 10, "date": "2026-10-15", "type": "income",
"amount_cents": 7500, "currency": "EUR", "account_id": 3,
"account_name": "Zweitkonto", "payee": "Dauerauftrag",
"description": null, "category_id": 4}}]}
"""
let stand = try JSONDecoder().decode(Uebertragsvorschlaege.self,
from: Data(roh.utf8))
let paar = try #require(stand.vorschlaege.first)
// Die ID muss unabhaengig von der Reihenfolge sein, sonst zeigt die
// Liste dasselbe Paar als zwei Eintraege.
#expect(paar.id == "9-10")
// Eine zugeordnete Buchung faellt durchs Verbinden aus dem Budget.
#expect(paar.hatKategorie)
}
}
extension ServerantwortenTests {
@Test("Eingang, Dubletten und Uebertragsvorschlaege")
func arbeitsliste() throws {
let eingang = try lies([Eingangseintrag].self, "eingang")
#expect(!eingang.isEmpty)
#expect(eingang.allSatisfy { $0.currency.isEmpty == false })
let dubletten = try lies(Dublettengruppen.self, "dubletten")
let gruppe = try #require(dubletten.gruppen.first)
#expect(gruppe.zeilen.count >= 2)
let vorschlaege = try lies(Uebertragsvorschlaege.self, "uebertragsvorschlaege")
#expect(vorschlaege.verknuepft == 0, "beim Fragen darf nichts verknuepft werden")
#expect(vorschlaege.vorschlaege.count == 1)
// Das Paar stand frueher zweimal drin, einmal je Richtung.
let ids = Set(vorschlaege.vorschlaege.map(\.id))
#expect(ids.count == vorschlaege.vorschlaege.count)
}
}
// MARK: - Nebenraeume
@Suite("Nebenraeume")
struct NebenraeumeTests {
private func felder<A: Encodable>(_ wert: A) throws -> [String: Any] {
let roh = try JSONEncoder().encode(wert)
return try #require(JSONSerialization.jsonObject(with: roh) as? [String: Any])
}
@Test("Eine Teilaenderung an Kategorie und Tag bleibt teilweise")
func teilaenderungen() throws {
// Dieselbe Falle wie bei Buchung und Konto: Ein nicht angefasstes
// Feld als null zu schicken loeschte Symbol, Farbe und Gruppe mit.
#expect(try felder(KategorieAendern(isArchived: true)).keys.sorted()
== ["is_archived"])
#expect(try felder(TagAendern(color: "#2a78d6")).keys.sorted() == ["color"])
}
@Test("Die Feldnamen der Kategorie sind die des Backends")
func kategorieFeldnamen() throws {
let neu = try felder(KategorieAnlegen(groupId: 2, name: "Spartopf",
spendingIsGood: true))
#expect(neu["group_id"] as? Int == 2)
#expect(neu["spending_is_good"] as? Bool == true)
#expect(neu["excluded_from_stats"] as? Bool == false)
}
@Test("Ein Abruf ohne zugeordnetes Konto sagt das auch")
func abrufOhneZuordnung() throws {
// Sonst meldet die App "0 Umsaetze geholt" und der Nutzer sucht den
// Fehler bei der Bank statt bei der fehlenden Zuordnung.
let roh = """
{"geholt": 0, "neu": 0, "saldi": {}, "ohne_zuordnung": true}
"""
let ergebnis = try JSONDecoder().decode(Abrufergebnis.self, from: Data(roh.utf8))
#expect(ergebnis.ohneZuordnung == true)
#expect(ergebnis.meldung != String(format: String(localized: "bank.abrufErgebnis"), 0, 0))
}
@Test("Ein voller Abruf liest sich trotz der vielen Felder")
func vollerAbruf() throws {
let roh = """
{"uebertraege": 1, "geholt": 42, "neu": 7, "gebucht": 5, "vorgemerkt": 2,
"doppelt": 0, "saldi": {}, "ab": "2026-10-01", "ohne_zuordnung": false}
"""
let ergebnis = try JSONDecoder().decode(Abrufergebnis.self, from: Data(roh.utf8))
#expect(ergebnis.geholt == 42 && ergebnis.neu == 7)
#expect(ergebnis.uebertraege == 1)
}
}
extension ServerantwortenTests {
@Test("Erkennungswoerter, zweiter Faktor und Bankverbindungen")
func nebenraeume() throws {
let woerter = try lies([Erkennungswort].self, "schlagwoerter_der_kategorie")
#expect(!woerter.isEmpty)
#expect(woerter.allSatisfy { !$0.word.isEmpty })
// Leer, solange niemand oft genug korrigiert hat - der Typ muss sich
// trotzdem lesen lassen.
_ = try lies([MarkiertesWort].self, "markierteSchlagwoerter")
let faktor = try lies(ZweiterFaktorStand.self, "zweiterFaktor")
#expect(faktor.enabled == false)
// Bleibt leer: Eine Anbindung laesst sich ohne echten Anbieter nicht
// herstellen. Die Felder von Bankverbindung deckt dieser Test NICHT
// ab - wer sie aendert, prueft von Hand.
#expect(try lies([Bankverbindung].self, "bankverbindungen").isEmpty)
}
}
+377
View File
@@ -0,0 +1,377 @@
/* Certain für iOS – Deutsch (Grundsprache)
Die Schlüssel sind nach Bereich benannt. Bitte beim Ergänzen die englische
Fassung gleich mitpflegen; im Web stehen de.json und en.json zeilengleich,
und das ist der Grund, warum dort nie etwas fehlt. */
/* --- Allgemein --- */
"allgemein.nochmal" = "Nochmal versuchen";
"allgemein.abbrechen" = "Abbrechen";
"allgemein.abmelden" = "Abmelden";
"allgemein.unbekannt" = "Unbekannt";
"allgemein.sichern" = "Sichern";
"allgemein.ok" = "OK";
"allgemein.fertig" = "Fertig";
"allgemein.bearbeiten" = "Bearbeiten";
"allgemein.loeschen" = "Löschen";
/* --- Reiter --- */
"reiter.dashboard" = "Übersicht";
"reiter.budget" = "Budget";
"reiter.buchungen" = "Buchungen";
"reiter.abos" = "Verträge";
"reiter.arbeitsliste" = "Eingang";
"reiter.konten" = "Konten";
"reiter.einstellungen" = "Einstellungen";
/* --- Fehler --- */
"fehler.titel" = "Das hat nicht geklappt";
"fehler.unbekannt" = "Unbekannter Fehler.";
"fehler.keineVerbindung" = "Keine Verbindung zum Server: %@";
"fehler.keinCertainServer" = "Unter dieser Adresse antwortet zwar etwas, aber kein Certain.";
"fehler.nichtAngemeldet" = "Die Anmeldung dieses Geräts gilt nicht mehr. Vermutlich wurde sie am Rechner widerrufen.";
"fehler.passwortWechseln" = "Bevor es weitergeht, muss das Passwort gewechselt werden.";
"fehler.zweiterFaktor" = "Dieses Konto braucht einen zweiten Faktor.";
"fehler.nichtGefunden" = "Gibt es nicht.";
"fehler.server" = "Der Server meldet einen Fehler (%d).";
"fehler.antwortUnlesbar" = "Die Antwort des Servers passt nicht zu dem, was die App erwartet: %@";
/* --- Offline --- */
"stand.hinweis" = "Stand von %@ – der Server ist gerade nicht erreichbar.";
/* --- Server-Adresse --- */
"server.titel" = "Mit Certain verbinden";
"server.ueberschrift" = "Adresse";
"server.platzhalter" = "budget.beispiel.com";
"server.erklaerung" = "Dieselbe Adresse, unter der du Certain im Browser öffnest. Ohne https davor geht auch.";
"server.verbinden" = "Verbinden";
"server.keineAdresse" = "Das sieht nicht nach einer Adresse aus.";
/* --- Anmeldung --- */
"anmeldung.titel" = "Anmelden";
"anmeldung.benutzer" = "Benutzername";
"anmeldung.passwort" = "Passwort";
"anmeldung.anmelden" = "Anmelden";
"anmeldung.erklaerung" = "Nur dieses eine Mal. Danach merkt sich das Gerät einen eigenen Schlüssel, und dein Passwort wird nirgends gespeichert.";
"anmeldung.andererServer" = "Anderen Server wählen";
"anmeldung.aktuellerServer" = "Verbunden mit %@";
"anmeldung.markeOhneSchluessel" = "Der Server hat keinen Schlüssel zurückgegeben. Bitte nochmal versuchen.";
/* --- Zweiter Faktor --- */
"zweifaktor.titel" = "Zweiter Faktor";
"zweifaktor.ueberschrift" = "Code";
"zweifaktor.platzhalterCode" = "123456";
"zweifaktor.platzhalterWiederherstellung" = "ABCDE-FGHIJ";
"zweifaktor.erklaerungCode" = "Die sechs Ziffern aus deiner Authenticator-App.";
"zweifaktor.erklaerungWiederherstellung" = "Einer der Codes, die du beim Einrichten bekommen hast. Jeder gilt nur einmal.";
"zweifaktor.bestaetigen" = "Bestätigen";
"zweifaktor.dochWiederherstellung" = "Stattdessen einen Wiederherstellungscode benutzen";
"zweifaktor.dochCode" = "Stattdessen den Code aus der App benutzen";
"zweifaktor.abbrechen" = "Anmeldung abbrechen";
"zweifaktor.verbraucht" = "Zu viele Fehlversuche. Bitte von vorn anmelden.";
/* --- Zweiter Faktor fehlt noch --- */
"zweifaktorEinrichten.titel" = "Zweiter Faktor nötig";
"zweifaktorEinrichten.erklaerung" = "Für dieses Konto verlangt die Richtlinie einen zweiten Faktor. Einrichten lässt er sich bisher nur im Browser – in der App kommt das noch.";
"zweifaktorEinrichten.imBrowser" = "Im Browser öffnen";
/* --- Passwortwechsel --- */
"passwort.titel" = "Passwort wechseln";
"passwort.bisher" = "Bisheriges Passwort";
"passwort.neu" = "Neues Passwort";
"passwort.wiederholen" = "Noch einmal";
"passwort.erklaerung" = "Für dieses Konto steht ein Passwortwechsel an. Vorher lässt der Server nichts anderes zu.";
"passwort.stimmtNichtUeberein" = "Die beiden stimmen nicht überein.";
"passwort.speichern" = "Passwort wechseln";
/* --- Konten --- */
"konten.titel" = "Konten";
"konten.leerTitel" = "Noch keine Konten";
"konten.leerErklaerung" = "Lege dein erstes Konto im Browser an – in der App kommt das in einer späteren Stufe.";
"konten.archivierteZeigen" = "Archivierte zeigen";
"konten.archiviert" = "archiviert";
"konten.gebunden" = "Gebundenes Geld";
"konten.gebundenSumme" = "Davon gebunden";
"konten.verfuegbar" = "Verfügbar";
"konten.bankWeichtAb" = "Die Bank meldet einen anderen Saldo";
/* --- Kontoarten --- */
"kontoart.girokonto" = "Girokonto";
"kontoart.sparkonto" = "Sparkonto";
"kontoart.bargeld" = "Bargeld";
"kontoart.kreditkarte" = "Kreditkarte";
"kontoart.depot" = "Depot";
"kontoart.sonstiges" = "Sonstiges";
/* --- Einstellungen --- */
"einstellungen.zugang" = "Zugang";
"einstellungen.angemeldetAls" = "Angemeldet als";
"einstellungen.server" = "Server";
"einstellungen.markeErklaerung" = "Dieses Gerät hat einen eigenen Schlüssel. Du kannst ihn am Rechner unter Einstellungen → Gerätemarken jederzeit widerrufen; dann meldet sich die App sofort ab.";
"einstellungen.ausDemServer" = "Vom Server";
"einstellungen.hauptwaehrung" = "Hauptwährung";
"einstellungen.budgetStarttag" = "Budget beginnt am";
"einstellungen.abmeldenFrage" = "Von diesem Gerät abmelden?";
"einstellungen.abmeldenErklaerung" = "Beim Abmelden wird der Schlüssel dieses Geräts auch am Server entfernt.";
/* --- Dashboard --- */
"dashboard.bilanz" = "Dieser Monat";
"dashboard.einnahmen" = "Einnahmen";
"dashboard.ausgaben" = "Ausgaben";
"dashboard.budgetfortschritt" = "Budget";
"dashboard.keinBudget" = "Für diesen Monat ist noch nichts geplant.";
"dashboard.kontostaende" = "Konten";
"dashboard.letzteBuchungen" = "Zuletzt";
/* --- Budget --- */
"budget.einnahmen" = "Geplante Einnahmen";
"budget.uebrig" = "übrig";
"budget.vonGeplant" = "%1$@ von %2$@ ausgegeben";
"budget.vonBetrag" = "von %@";
"budget.angepasst" = "angepasst";
"budget.vorigerMonat" = "Voriger Monat";
"budget.naechsterMonat" = "Nächster Monat";
"budget.ausVertraegenHinweis" = "%1$d Verträge: %2$@";
"budget.ausVertraegenBerechnet" = "Berechnet aus %1$d Verträgen: %2$@";
"budget.vertraegeOhneKategorie" = "%1$d Verträge ohne Kategorie (%2$@) fehlen in diesen Summen.";
"budget.ohneKurs" = "Kein Wechselkurs für %@ – diese Beträge fehlen in den Summen.";
/* --- Buchungen --- */
"buchungen.suchen" = "Empfänger, Zweck, Notiz";
"buchungen.konto" = "Konto";
"buchungen.alleKonten" = "Alle Konten";
"buchungen.art" = "Art";
"buchungen.alleArten" = "Alle Arten";
"buchungen.leerTitel" = "Keine Buchungen";
"buchungen.leerErklaerung" = "Sobald etwas gebucht ist, steht es hier.";
"buchungen.leerGefiltert" = "Zu dieser Suche gibt es nichts. Andere Filter versuchen?";
"buchung.ohneBezeichnung" = "Ohne Bezeichnung";
"buchung.ohneKategorie" = "Ohne Kategorie";
"buchung.uebertrag" = "Übertrag";
/* --- Buchungsarten --- */
"buchungsart.ausgabe" = "Ausgabe";
"buchungsart.einnahme" = "Einnahme";
"buchungsart.uebertrag" = "Übertrag";
/* --- Verträge --- */
"abos.leerTitel" = "Keine Verträge";
"abos.leerErklaerung" = "Hier stehen Abos und Verträge, sobald welche angelegt sind.";
"abos.beendeteZeigen" = "Beendete zeigen";
"abos.beendet" = "beendet";
"abos.jaehrlichUndAnzahl" = "%1$@ im Jahr · %2$d aktiv";
"abos.ohneKurs" = "Kein Wechselkurs für %@ – diese Verträge fehlen in der Summe.";
"abo.monatlich" = "monatlich";
"abo.vierteljaehrlich" = "vierteljährlich";
"abo.halbjaehrlich" = "halbjährlich";
"abo.jaehrlich" = "jährlich";
"abo.alleNMonate" = "alle %d Monate";
"abo.proMonat" = "%@ im Monat";
"abo.naechste" = "nächste am %@";
"abo.inTagen" = "fällig in %d Tagen";
/* --- Buchung bearbeiten --- */
"buchung.neu" = "Neue Buchung";
"buchung.aendern" = "Buchung ändern";
"buchung.datum" = "Datum";
"buchung.betrag" = "Betrag";
"buchung.konto" = "Konto";
"buchung.konto.keines" = "Kein Konto";
"buchung.zielkonto" = "Auf Konto";
"buchung.kategorie" = "Kategorie";
"buchung.empfaenger" = "Empfänger";
"buchung.verwendungszweck" = "Verwendungszweck";
"buchung.notiz" = "Notiz";
"buchung.schlagwoerter" = "Schlagwörter";
"buchung.keineSchlagwoerter" = "Noch keine Schlagwörter angelegt. Das geht am Rechner.";
"buchung.uebertragErklaerung" = "Ein Übertrag verschiebt Geld zwischen deinen Konten. Er zählt in keiner Auswertung mit und braucht deshalb keine Kategorie.";
"buchung.loeschen" = "Buchung löschen";
"buchung.loeschenFrage" = "Diese Buchung löschen?";
/* --- Budget bearbeiten --- */
"budget.geplant" = "Geplant";
"budget.zuschlag" = "Zuschlag";
"budget.ausVertraegen" = "Aus Verträgen";
"budget.ergibt" = "Ergibt";
"budget.monatSetzen" = "Nur dieser Monat";
"budget.vorlageSetzen" = "Standard-Budget";
"budget.vorlage" = "Vorlage";
"budget.ausVertraegenRechnen" = "Aus Verträgen rechnen";
"budget.berechnetErklaerung" = "Der Soll-Betrag ergibt sich aus den Verträgen dieser Kategorie plus Zuschlag. Ändert sich ein Vertrag, ändert sich das Budget mit.";
"budget.festErklaerung" = "Ein fester Betrag, den du selbst setzt.";
"budget.vorlageHolen" = "Vorlage in diesen Monat holen";
"budget.alsVorlageSetzen" = "Diesen Monat als Standard festlegen";
"budget.zuruecksetzen" = "Monat zurücksetzen";
"budget.zuruecksetzenFrage" = "Diesen Monat zurücksetzen?";
"budget.zuruecksetzenErklaerung" = "Alle Anpassungen dieses Monats fallen weg. Danach gilt wieder das Standard-Budget.";
/* --- Vertrag bearbeiten --- */
"abo.neu" = "Neuer Vertrag";
"abo.aendern" = "Vertrag ändern";
"abo.name" = "Name";
"abo.betrag" = "Betrag je Zahlung";
"abo.rhythmus" = "Rhythmus";
"abo.konto" = "Wird abgebucht von";
"abo.kategorieErklaerung" = "Die Kategorie entscheidet, in welcher Budget-Zeile dieser Vertrag mitzählt.";
"abo.faelligkeitKennen" = "Nächste Zahlung bekannt";
"abo.naechsteZahlung" = "Nächste Zahlung";
"abo.erkennungstext" = "Erkennungstext";
"abo.erkennungErklaerung" = "Woran die Erkennung diesen Vertrag in einer Buchung wiedererkennt - meist ein Teil des Empfängernamens.";
"abo.laeuftNoch" = "Läuft noch";
"abo.beendenStattLoeschen" = "Beenden statt löschen hält fest, was er gekostet hat. Löschen entfernt ihn ganz.";
"abo.loeschen" = "Vertrag löschen";
"abo.loeschenFrage" = "Diesen Vertrag löschen?";
"abo.loeschenErklaerung" = "Die zugeordneten Buchungen bleiben, sie verlieren nur das Etikett.";
/* --- Konto bearbeiten --- */
"konto.neu" = "Neues Konto";
"konto.aendern" = "Konto ändern";
"konto.name" = "Name";
"konto.art" = "Art";
"konto.waehrung" = "Währung";
"konto.anfangssaldo" = "Anfangssaldo";
"konto.stichtagKennen" = "Stichtag angeben";
"konto.stichtag" = "Stichtag";
"konto.anfangssaldoErklaerung" = "Der Stand zu Beginn. Ohne Stichtag gilt er von Anfang an; mit Stichtag zählen nur Buchungen ab diesem Tag darauf.";
"konto.kreditrahmenKennen" = "Kreditrahmen angeben";
"konto.kreditrahmen" = "Kreditrahmen";
"konto.kreditrahmenErklaerung" = "Was die Karte ausgeben darf - kein Guthaben.";
"konto.gebunden" = "Gebundenes Geld";
"konto.gebundenErklaerung" = "Geld, an das du nicht herankommst, etwa Säule 3a. Es zählt nicht als verfügbar.";
"konto.archiviert" = "Archiviert";
"konto.archivierenStattLoeschen" = "Archivieren blendet das Konto aus und behält seine Buchungen. Löschen geht nur, solange keine daran hängen.";
"konto.loeschen" = "Konto löschen";
"konto.loeschenFrage" = "Dieses Konto löschen?";
"konto.loeschenErklaerung" = "Das geht nur, wenn keine Buchung mehr daran hängt. Sonst archivieren.";
/* --- Arbeitsliste --- */
"arbeit.leerTitel" = "Nichts zu tun";
"arbeit.leerErklaerung" = "Keine offenen Einträge, keine Übertragsvorschläge, keine Dubletten. So soll es sein.";
"arbeit.eingang" = "Zu buchen";
"arbeit.uebertraege" = "Könnten Überträge sein";
"arbeit.ohneKategorie" = "Ohne Kategorie";
"arbeit.dubletten" = "Sieht doppelt aus";
"arbeit.dublettenErklaerung" = "Gleiches Konto, gleicher Tag, gleicher Betrag. Ob es wirklich dieselbe Zahlung ist, weisst nur du - verschiedene Kennungen bedeuten, dass die Bank sie als verschieden gemeldet hat.";
"arbeit.buchen" = "Buchen";
"arbeit.alleBuchen" = "Alle buchen, bei denen das Konto feststeht";
"arbeit.alsUebertrag" = "Übertrag";
"arbeit.verwerfen" = "Verwerfen";
"arbeit.verbinden" = "Verbinden";
"arbeit.keinUebertrag" = "Kein Übertrag";
"arbeit.istRichtigSo" = "Ist richtig so";
"arbeit.unklarHinweis" = "Die Erkennung war unsicher - erst ansehen.";
"arbeit.hatKategorie" = "Eine der beiden hat eine Kategorie. Durchs Verbinden fällt sie aus dem Budget.";
"arbeit.ohneKonto" = "ohne Konto";
"arbeit.rueckfrage" = "Sicher?";
"arbeit.trotzdem" = "Trotzdem";
"arbeit.zielkontoErklaerung" = "Es entsteht eine Buchung, die das Geld von einem Konto aufs andere bewegt. Sie zählt auf beiden Seiten im Saldo und in keiner Auswertung.";
/* --- Eingang: Quelle und Stand --- */
"eingang.quelle.kurzbefehl" = "Kurzbefehl";
"eingang.quelle.postfach" = "Postfach";
"eingang.quelle.bank" = "Bank";
"eingang.stand.gebucht" = "gebucht";
"eingang.stand.offen" = "offen";
"eingang.stand.unklar" = "unklar";
"eingang.stand.verworfen" = "verworfen";
/* --- Nebenräume --- */
"nebenraeume.titel" = "Verwalten";
"nebenraeume.kategorien" = "Kategorien";
"nebenraeume.tags" = "Schlagwörter";
"nebenraeume.bank" = "Bankanbindung";
/* --- Kategorien --- */
"kategorien.leer" = "Noch keine Kategorien.";
"kategorien.neu" = "Neue Kategorie";
"kategorien.aendern" = "Kategorie ändern";
"kategorien.neueGruppe" = "Neue Gruppe";
"kategorien.gruppe" = "Gruppe";
"kategorien.art" = "Art";
"kategorien.ausgaben" = "Ausgaben";
"kategorien.einnahmen" = "Einnahmen";
"kategorien.archivierteZeigen" = "Archivierte zeigen";
"kategorien.sparen" = "Sparen";
"kategorien.ohneAuswertung" = "ohne Auswertung";
"kategorien.istSparen" = "Ausgabe, die kein Verlust ist";
"kategorien.istSparenErklaerung" = "Für Sparen und Rücklagen. Mehr auszugeben als geplant ist hier keine Warnung, sondern gut.";
"kategorien.ohneAuswertungSchalter" = "Aus Auswertungen heraushalten";
"kategorien.ohneAuswertungErklaerung" = "Für durchlaufende Posten. Die Buchungen bleiben, zählen aber in keinem Diagramm und keiner Summe mit.";
"kategorien.loeschen" = "Kategorie löschen";
"kategorien.loeschenFrage" = "Diese Kategorie löschen?";
"kategorien.loeschenErklaerung" = "Das geht nur, solange keine Buchung daran hängt.";
"kategorien.archivierenStattLoeschen" = "Archivieren blendet die Kategorie aus und behält ihre Buchungen. Löschen geht nur, solange keine daran hängt.";
"kategorien.gruppeLoeschen" = "Gruppe löschen";
"kategorien.gruppeLoeschenFrage" = "Diese Gruppe löschen?";
"kategorien.gruppeLoeschenErklaerung" = "Das geht nur, solange keine Kategorie mehr darin steht.";
"kategorien.erkennungswoerter" = "Erkennungswörter";
"kategorien.wortHinzufuegen" = "Wort hinzufügen";
"kategorien.erkennungErklaerung" = "Trifft eines dieser Wörter auf Empfänger oder Verwendungszweck zu, ordnet der Import diese Kategorie zu. Das längste Wort gewinnt. Wirkt nur auf künftige Importe - an bestehenden Buchungen ändert sich nichts.";
"kategorien.markiertTitel" = "Wörter, die oft korrigiert werden";
"kategorien.markiertErklaerung" = "Diese Wörter ordnen häufig falsch zu. Behalten, wenn es Einzelfälle waren - entfernen, wenn das Wort nicht taugt.";
"kategorien.markiertZaehler" = "%1$d-mal angewendet, %2$d-mal korrigiert";
"kategorien.behalten" = "Behalten";
"kategorien.entfernen" = "Entfernen";
/* --- Schlagwörter (Tags) --- */
"tags.neu" = "Neues Schlagwort";
"tags.erklaerung" = "Schlagwörter liegen quer zur Kategorie: Eine Buchung kann mehrere tragen. Gut für alles, was sich nicht einordnen lässt - Urlaub Island, steuerlich absetzbar, zurückfordern.";
/* --- Einstellungen --- */
"einstellungen.erscheinungsbild" = "Erscheinungsbild";
"einstellungen.system" = "System";
"einstellungen.hell" = "Hell";
"einstellungen.dunkel" = "Dunkel";
"einstellungen.serverweitErklaerung" = "Diese Einstellungen gelten für dein Konto, nicht nur für dieses Gerät - auch im Browser.";
/* --- Zweiter Faktor einrichten --- */
"zweifaktor.stand" = "Zweiter Faktor";
"zweifaktor.aktiv" = "Aktiv";
"zweifaktor.inaktiv" = "Nicht eingerichtet";
"zweifaktor.aktivErklaerung" = "Beim Anmelden im Browser wird zusätzlich ein Code verlangt. Abschalten geht nur am Rechner - es verlangt dein Passwort, und das speichert diese App bewusst nicht.";
"zweifaktor.inaktivErklaerung" = "Ein zweiter Faktor schützt dein Konto, falls jemand dein Passwort kennt.";
"zweifaktor.einrichten" = "Einrichten";
"zweifaktor.schrittEins" = "Schritt 1: In die Authenticator-App übernehmen";
"zweifaktor.schrittZwei" = "Schritt 2: Mit einem Code bestätigen";
"zweifaktor.inAppOeffnen" = "In der Authenticator-App öffnen";
"zweifaktor.geheimnis" = "Geheimnis";
"zweifaktor.geheimnisErklaerung" = "Der Knopf öffnet die App direkt. Geht das nicht, trägst du das Geheimnis von Hand ein.";
"zweifaktor.code" = "Sechsstelliger Code";
"zweifaktor.aktivieren" = "Scharfschalten";
"zweifaktor.rettungscodes" = "Rettungscodes";
"zweifaktor.rettungscodesErklaerung" = "Jeder Code lässt sich einmal statt eines App-Codes verwenden. Sie stehen genau hier und nie wieder - sichere sie jetzt.";
"zweifaktor.codesSichern" = "Codes sichern";
"zweifaktor.codesNotiert" = "Ich habe sie gesichert";
/* --- Bank --- */
"bank.leerTitel" = "Keine Bankanbindung";
"bank.leerErklaerung" = "Eine Bank verbinden geht nur am Rechner - die Bank leitet dorthin zurück. Abrufen kannst du sie danach von hier.";
"bank.land" = "Land";
"bank.zuletzt" = "Zuletzt abgerufen";
"bank.nie" = "noch nie";
"bank.gueltigBis" = "Gültig bis";
"bank.takt" = "Automatisch";
"bank.alleNMinuten" = "alle %d Minuten";
"bank.abrufen" = "Jetzt abrufen";
"bank.saldenAuffrischen" = "Nur Salden auffrischen";
"bank.saldenAufgefrischt" = "Die Salden sind aufgefrischt.";
"bank.abrufFertig" = "Abruf fertig";
"bank.abrufErgebnis" = "%1$d Umsätze geholt, davon %2$d neu.";
"bank.ohneZuordnung" = "Dieser Anbindung ist noch kein Konto zugeordnet. Das geht am Rechner.";
/* Bildschirmsperre */
"sperre.titel" = "Certain ist gesperrt";
"sperre.pin" = "PIN";
"sperre.faceid" = "Mit Face ID entsperren";
"sperre.touchid" = "Mit Touch ID entsperren";
"sperre.grund" = "Entsperrt Certain, ohne die PIN zu tippen.";
"sperre.falsch" = "Falsche PIN. Noch %d Versuche, dann meldet sich die App ab.";
"sperre.abmelden" = "PIN vergessen? Abmelden";
"pin.einrichtenTitel" = "PIN festlegen";
"pin.erklaerung" = "Sechs Ziffern. Du brauchst sie, wenn Face ID nicht greift – und bei jedem Start.";
"pin.neu" = "Neue PIN";
"pin.wiederholen" = "Noch einmal";
"pin.zuKurz" = "Die PIN muss sechs Ziffern haben.";
"pin.ungleich" = "Die beiden Eingaben sind verschieden.";
"pin.sichern" = "Festlegen";
"pin.aendern" = "PIN ändern";
+377
View File
@@ -0,0 +1,377 @@
/* Certain for iOS – English
Keys mirror de.lproj line for line. Keep them that way: in the web app
de.json and en.json are the same length, and that is why nothing is ever
missing there. */
/* --- General --- */
"allgemein.nochmal" = "Try again";
"allgemein.abbrechen" = "Cancel";
"allgemein.abmelden" = "Sign out";
"allgemein.unbekannt" = "Unknown";
"allgemein.sichern" = "Save";
"allgemein.ok" = "OK";
"allgemein.fertig" = "Done";
"allgemein.bearbeiten" = "Edit";
"allgemein.loeschen" = "Delete";
/* --- Tabs --- */
"reiter.dashboard" = "Overview";
"reiter.budget" = "Budget";
"reiter.buchungen" = "Transactions";
"reiter.abos" = "Subscriptions";
"reiter.arbeitsliste" = "Inbox";
"reiter.konten" = "Accounts";
"reiter.einstellungen" = "Settings";
/* --- Errors --- */
"fehler.titel" = "That did not work";
"fehler.unbekannt" = "Unknown error.";
"fehler.keineVerbindung" = "Cannot reach the server: %@";
"fehler.keinCertainServer" = "Something answers at this address, but it is not Certain.";
"fehler.nichtAngemeldet" = "This device is no longer signed in. It was probably revoked on the desktop.";
"fehler.passwortWechseln" = "The password has to be changed before anything else.";
"fehler.zweiterFaktor" = "This account needs a second factor.";
"fehler.nichtGefunden" = "Does not exist.";
"fehler.server" = "The server reports an error (%d).";
"fehler.antwortUnlesbar" = "The server's answer does not match what the app expects: %@";
/* --- Offline --- */
"stand.hinweis" = "As of %@ – the server cannot be reached right now.";
/* --- Server address --- */
"server.titel" = "Connect to Certain";
"server.ueberschrift" = "Address";
"server.platzhalter" = "budget.example.com";
"server.erklaerung" = "The same address you open Certain with in your browser. Leaving off https is fine.";
"server.verbinden" = "Connect";
"server.keineAdresse" = "That does not look like an address.";
/* --- Sign in --- */
"anmeldung.titel" = "Sign in";
"anmeldung.benutzer" = "Username";
"anmeldung.passwort" = "Password";
"anmeldung.anmelden" = "Sign in";
"anmeldung.erklaerung" = "Just this once. Afterwards the device keeps a key of its own, and your password is stored nowhere.";
"anmeldung.andererServer" = "Choose a different server";
"anmeldung.aktuellerServer" = "Connected to %@";
"anmeldung.markeOhneSchluessel" = "The server returned no key. Please try again.";
/* --- Second factor --- */
"zweifaktor.titel" = "Second factor";
"zweifaktor.ueberschrift" = "Code";
"zweifaktor.platzhalterCode" = "123456";
"zweifaktor.platzhalterWiederherstellung" = "ABCDE-FGHIJ";
"zweifaktor.erklaerungCode" = "The six digits from your authenticator app.";
"zweifaktor.erklaerungWiederherstellung" = "One of the codes you got when setting this up. Each works once.";
"zweifaktor.bestaetigen" = "Confirm";
"zweifaktor.dochWiederherstellung" = "Use a recovery code instead";
"zweifaktor.dochCode" = "Use the code from the app instead";
"zweifaktor.abbrechen" = "Cancel sign-in";
"zweifaktor.verbraucht" = "Too many wrong attempts. Please start over.";
/* --- Second factor missing --- */
"zweifaktorEinrichten.titel" = "Second factor required";
"zweifaktorEinrichten.erklaerung" = "The policy requires a second factor for this account. Setting one up still only works in the browser; the app will get it later.";
"zweifaktorEinrichten.imBrowser" = "Open in browser";
/* --- Password change --- */
"passwort.titel" = "Change password";
"passwort.bisher" = "Current password";
"passwort.neu" = "New password";
"passwort.wiederholen" = "Once more";
"passwort.erklaerung" = "This account has to change its password. The server allows nothing else until then.";
"passwort.stimmtNichtUeberein" = "These two do not match.";
"passwort.speichern" = "Change password";
/* --- Accounts --- */
"konten.titel" = "Accounts";
"konten.leerTitel" = "No accounts yet";
"konten.leerErklaerung" = "Create your first account in the browser; the app will get this in a later stage.";
"konten.archivierteZeigen" = "Show archived";
"konten.archiviert" = "archived";
"konten.gebunden" = "Money set aside";
"konten.gebundenSumme" = "Of which set aside";
"konten.verfuegbar" = "Available";
"konten.bankWeichtAb" = "The bank reports a different balance";
/* --- Account types --- */
"kontoart.girokonto" = "Current account";
"kontoart.sparkonto" = "Savings account";
"kontoart.bargeld" = "Cash";
"kontoart.kreditkarte" = "Credit card";
"kontoart.depot" = "Securities account";
"kontoart.sonstiges" = "Other";
/* --- Settings --- */
"einstellungen.zugang" = "Access";
"einstellungen.angemeldetAls" = "Signed in as";
"einstellungen.server" = "Server";
"einstellungen.markeErklaerung" = "This device holds a key of its own. You can revoke it any time on the desktop under Settings → Device keys; the app signs out immediately.";
"einstellungen.ausDemServer" = "From the server";
"einstellungen.hauptwaehrung" = "Main currency";
"einstellungen.budgetStarttag" = "Budget starts on";
"einstellungen.abmeldenFrage" = "Sign out on this device?";
"einstellungen.abmeldenErklaerung" = "Signing out also removes this device's key on the server.";
/* --- Dashboard --- */
"dashboard.bilanz" = "This month";
"dashboard.einnahmen" = "Income";
"dashboard.ausgaben" = "Expenses";
"dashboard.budgetfortschritt" = "Budget";
"dashboard.keinBudget" = "Nothing planned for this month yet.";
"dashboard.kontostaende" = "Accounts";
"dashboard.letzteBuchungen" = "Recent";
/* --- Budget --- */
"budget.einnahmen" = "Planned income";
"budget.uebrig" = "left";
"budget.vonGeplant" = "%1$@ of %2$@ spent";
"budget.vonBetrag" = "of %@";
"budget.angepasst" = "adjusted";
"budget.vorigerMonat" = "Previous month";
"budget.naechsterMonat" = "Next month";
"budget.ausVertraegenHinweis" = "%1$d subscriptions: %2$@";
"budget.ausVertraegenBerechnet" = "Calculated from %1$d subscriptions: %2$@";
"budget.vertraegeOhneKategorie" = "%1$d subscriptions without a category (%2$@) are missing from these totals.";
"budget.ohneKurs" = "No exchange rate for %@ – those amounts are missing from the totals.";
/* --- Transactions --- */
"buchungen.suchen" = "Payee, description, note";
"buchungen.konto" = "Account";
"buchungen.alleKonten" = "All accounts";
"buchungen.art" = "Type";
"buchungen.alleArten" = "All types";
"buchungen.leerTitel" = "No transactions";
"buchungen.leerErklaerung" = "Once something is booked, it shows up here.";
"buchungen.leerGefiltert" = "Nothing matches this search. Try different filters?";
"buchung.ohneBezeichnung" = "Untitled";
"buchung.ohneKategorie" = "No category";
"buchung.uebertrag" = "Transfer";
/* --- Transaction types --- */
"buchungsart.ausgabe" = "Expense";
"buchungsart.einnahme" = "Income";
"buchungsart.uebertrag" = "Transfer";
/* --- Subscriptions --- */
"abos.leerTitel" = "No subscriptions";
"abos.leerErklaerung" = "Subscriptions and contracts show up here once you add them.";
"abos.beendeteZeigen" = "Show cancelled";
"abos.beendet" = "cancelled";
"abos.jaehrlichUndAnzahl" = "%1$@ per year · %2$d active";
"abos.ohneKurs" = "No exchange rate for %@ – those subscriptions are missing from the total.";
"abo.monatlich" = "monthly";
"abo.vierteljaehrlich" = "quarterly";
"abo.halbjaehrlich" = "twice a year";
"abo.jaehrlich" = "yearly";
"abo.alleNMonate" = "every %d months";
"abo.proMonat" = "%@ per month";
"abo.naechste" = "next on %@";
"abo.inTagen" = "due in %d days";
/* --- Edit transaction --- */
"buchung.neu" = "New transaction";
"buchung.aendern" = "Edit transaction";
"buchung.datum" = "Date";
"buchung.betrag" = "Amount";
"buchung.konto" = "Account";
"buchung.konto.keines" = "No account";
"buchung.zielkonto" = "To account";
"buchung.kategorie" = "Category";
"buchung.empfaenger" = "Payee";
"buchung.verwendungszweck" = "Description";
"buchung.notiz" = "Note";
"buchung.schlagwoerter" = "Tags";
"buchung.keineSchlagwoerter" = "No tags yet. You can add them on the desktop.";
"buchung.uebertragErklaerung" = "A transfer moves money between your own accounts. It counts in no report and therefore needs no category.";
"buchung.loeschen" = "Delete transaction";
"buchung.loeschenFrage" = "Delete this transaction?";
/* --- Edit budget --- */
"budget.geplant" = "Planned";
"budget.zuschlag" = "Extra";
"budget.ausVertraegen" = "From subscriptions";
"budget.ergibt" = "Totals";
"budget.monatSetzen" = "This month only";
"budget.vorlageSetzen" = "Default budget";
"budget.vorlage" = "Template";
"budget.ausVertraegenRechnen" = "Calculate from subscriptions";
"budget.berechnetErklaerung" = "The planned amount comes from this category's subscriptions plus the extra. Change a subscription and the budget follows.";
"budget.festErklaerung" = "A fixed amount you set yourself.";
"budget.vorlageHolen" = "Load template into this month";
"budget.alsVorlageSetzen" = "Make this month the default";
"budget.zuruecksetzen" = "Reset month";
"budget.zuruecksetzenFrage" = "Reset this month?";
"budget.zuruecksetzenErklaerung" = "All adjustments to this month are dropped. The default budget applies again.";
/* --- Edit subscription --- */
"abo.neu" = "New subscription";
"abo.aendern" = "Edit subscription";
"abo.name" = "Name";
"abo.betrag" = "Amount per payment";
"abo.rhythmus" = "Billed";
"abo.konto" = "Charged to";
"abo.kategorieErklaerung" = "The category decides which budget line this subscription counts towards.";
"abo.faelligkeitKennen" = "Next payment known";
"abo.naechsteZahlung" = "Next payment";
"abo.erkennungstext" = "Match text";
"abo.erkennungErklaerung" = "How the matcher recognises this subscription in a transaction - usually part of the payee name.";
"abo.laeuftNoch" = "Still running";
"abo.beendenStattLoeschen" = "Cancelling keeps the record of what it cost. Deleting removes it entirely.";
"abo.loeschen" = "Delete subscription";
"abo.loeschenFrage" = "Delete this subscription?";
"abo.loeschenErklaerung" = "The linked transactions stay; they only lose the label.";
/* --- Edit account --- */
"konto.neu" = "New account";
"konto.aendern" = "Edit account";
"konto.name" = "Name";
"konto.art" = "Type";
"konto.waehrung" = "Currency";
"konto.anfangssaldo" = "Opening balance";
"konto.stichtagKennen" = "Set an opening date";
"konto.stichtag" = "Opening date";
"konto.anfangssaldoErklaerung" = "The balance to start from. Without a date it applies from the beginning; with one, only transactions from that day count on top.";
"konto.kreditrahmenKennen" = "Set a credit limit";
"konto.kreditrahmen" = "Credit limit";
"konto.kreditrahmenErklaerung" = "What the card may spend - not a balance.";
"konto.gebunden" = "Locked money";
"konto.gebundenErklaerung" = "Money you cannot reach, such as a pension account. It does not count as available.";
"konto.archiviert" = "Archived";
"konto.archivierenStattLoeschen" = "Archiving hides the account and keeps its transactions. Deleting only works while none are attached.";
"konto.loeschen" = "Delete account";
"konto.loeschenFrage" = "Delete this account?";
"konto.loeschenErklaerung" = "Only possible while no transaction is attached. Archive it otherwise.";
/* --- Inbox / work list --- */
"arbeit.leerTitel" = "Nothing to do";
"arbeit.leerErklaerung" = "No pending entries, no transfer suggestions, no duplicates. Just as it should be.";
"arbeit.eingang" = "To book";
"arbeit.uebertraege" = "Might be transfers";
"arbeit.ohneKategorie" = "Without a category";
"arbeit.dubletten" = "Looks duplicated";
"arbeit.dublettenErklaerung" = "Same account, same day, same amount. Only you know whether it really is the same payment - different reference numbers mean the bank reported them as separate.";
"arbeit.buchen" = "Book";
"arbeit.alleBuchen" = "Book all that have an account";
"arbeit.alsUebertrag" = "Transfer";
"arbeit.verwerfen" = "Discard";
"arbeit.verbinden" = "Link";
"arbeit.keinUebertrag" = "Not a transfer";
"arbeit.istRichtigSo" = "That is correct";
"arbeit.unklarHinweis" = "The matcher was unsure - have a look first.";
"arbeit.hatKategorie" = "One of the two has a category. Linking takes it out of the budget.";
"arbeit.ohneKonto" = "no account";
"arbeit.rueckfrage" = "Are you sure?";
"arbeit.trotzdem" = "Do it anyway";
"arbeit.zielkontoErklaerung" = "This creates one transaction moving the money from one account to the other. It counts in both balances and in no report.";
/* --- Inbox: source and status --- */
"eingang.quelle.kurzbefehl" = "Shortcut";
"eingang.quelle.postfach" = "Mailbox";
"eingang.quelle.bank" = "Bank";
"eingang.stand.gebucht" = "booked";
"eingang.stand.offen" = "pending";
"eingang.stand.unklar" = "unclear";
"eingang.stand.verworfen" = "discarded";
/* --- Manage --- */
"nebenraeume.titel" = "Manage";
"nebenraeume.kategorien" = "Categories";
"nebenraeume.tags" = "Tags";
"nebenraeume.bank" = "Bank connection";
/* --- Categories --- */
"kategorien.leer" = "No categories yet.";
"kategorien.neu" = "New category";
"kategorien.aendern" = "Edit category";
"kategorien.neueGruppe" = "New group";
"kategorien.gruppe" = "Group";
"kategorien.art" = "Kind";
"kategorien.ausgaben" = "Expenses";
"kategorien.einnahmen" = "Income";
"kategorien.archivierteZeigen" = "Show archived";
"kategorien.sparen" = "saving";
"kategorien.ohneAuswertung" = "not counted";
"kategorien.istSparen" = "Spending that is not a loss";
"kategorien.istSparenErklaerung" = "For savings and reserves. Spending more than planned is no warning here - it is good news.";
"kategorien.ohneAuswertungSchalter" = "Keep out of reports";
"kategorien.ohneAuswertungErklaerung" = "For pass-through items. The transactions stay but count in no chart and no total.";
"kategorien.loeschen" = "Delete category";
"kategorien.loeschenFrage" = "Delete this category?";
"kategorien.loeschenErklaerung" = "Only possible while no transaction is attached.";
"kategorien.archivierenStattLoeschen" = "Archiving hides the category and keeps its transactions. Deleting only works while none are attached.";
"kategorien.gruppeLoeschen" = "Delete group";
"kategorien.gruppeLoeschenFrage" = "Delete this group?";
"kategorien.gruppeLoeschenErklaerung" = "Only possible while no category is left in it.";
"kategorien.erkennungswoerter" = "Match words";
"kategorien.wortHinzufuegen" = "Add a word";
"kategorien.erkennungErklaerung" = "If one of these words appears in the payee or description, the importer assigns this category. The longest word wins. Applies to future imports only - existing transactions stay as they are.";
"kategorien.markiertTitel" = "Words that get corrected a lot";
"kategorien.markiertErklaerung" = "These words often assign the wrong category. Keep them if those were one-offs - remove them if the word does not work.";
"kategorien.markiertZaehler" = "applied %1$d times, corrected %2$d times";
"kategorien.behalten" = "Keep";
"kategorien.entfernen" = "Remove";
/* --- Tags --- */
"tags.neu" = "New tag";
"tags.erklaerung" = "Tags sit across categories: one transaction can carry several. Good for everything that does not fit a category - Iceland trip, tax deductible, to be reimbursed.";
/* --- Settings --- */
"einstellungen.erscheinungsbild" = "Appearance";
"einstellungen.system" = "System";
"einstellungen.hell" = "Light";
"einstellungen.dunkel" = "Dark";
"einstellungen.serverweitErklaerung" = "These settings belong to your account, not just this device - the browser follows them too.";
/* --- Two-factor setup --- */
"zweifaktor.stand" = "Two-factor";
"zweifaktor.aktiv" = "Active";
"zweifaktor.inaktiv" = "Not set up";
"zweifaktor.aktivErklaerung" = "Signing in through the browser asks for a code as well. Turning it off only works on the desktop - it needs your password, and this app deliberately stores none.";
"zweifaktor.inaktivErklaerung" = "A second factor protects your account even if someone knows your password.";
"zweifaktor.einrichten" = "Set up";
"zweifaktor.schrittEins" = "Step 1: add it to your authenticator app";
"zweifaktor.schrittZwei" = "Step 2: confirm with a code";
"zweifaktor.inAppOeffnen" = "Open in authenticator app";
"zweifaktor.geheimnis" = "Secret";
"zweifaktor.geheimnisErklaerung" = "The button opens the app directly. If that does not work, type the secret in by hand.";
"zweifaktor.code" = "Six-digit code";
"zweifaktor.aktivieren" = "Turn on";
"zweifaktor.rettungscodes" = "Recovery codes";
"zweifaktor.rettungscodesErklaerung" = "Each code works once instead of an app code. They appear here and nowhere else - save them now.";
"zweifaktor.codesSichern" = "Save codes";
"zweifaktor.codesNotiert" = "I have saved them";
/* --- Bank --- */
"bank.leerTitel" = "No bank connection";
"bank.leerErklaerung" = "Connecting a bank only works on the desktop - the bank redirects there. You can fetch from here afterwards.";
"bank.land" = "Country";
"bank.zuletzt" = "Last fetched";
"bank.nie" = "never";
"bank.gueltigBis" = "Valid until";
"bank.takt" = "Automatically";
"bank.alleNMinuten" = "every %d minutes";
"bank.abrufen" = "Fetch now";
"bank.saldenAuffrischen" = "Refresh balances only";
"bank.saldenAufgefrischt" = "Balances refreshed.";
"bank.abrufFertig" = "Fetch complete";
"bank.abrufErgebnis" = "Fetched %1$d transactions, %2$d of them new.";
"bank.ohneZuordnung" = "This connection has no account assigned yet. That is done on the desktop.";
/* Screen lock */
"sperre.titel" = "Certain is locked";
"sperre.pin" = "PIN";
"sperre.faceid" = "Unlock with Face ID";
"sperre.touchid" = "Unlock with Touch ID";
"sperre.grund" = "Unlocks Certain without typing your PIN.";
"sperre.falsch" = "Wrong PIN. %d attempts left, then the app signs out.";
"sperre.abmelden" = "Forgot your PIN? Sign out";
"pin.einrichtenTitel" = "Set a PIN";
"pin.erklaerung" = "Six digits. You need them when Face ID does not work – and on every start.";
"pin.neu" = "New PIN";
"pin.wiederholen" = "Again";
"pin.zuKurz" = "The PIN must be six digits.";
"pin.ungleich" = "The two entries differ.";
"pin.sichern" = "Set PIN";
"pin.aendern" = "Change PIN";
+588
View File
@@ -0,0 +1,588 @@
# Ü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: 21.10.2026, Commit `d290930`. 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 Rahmenbedingung, die es nicht mehr gibt
Bis zum 21.10.2026 stand hier: es gibt kein Xcode, der Swift-Code wird blind
geschrieben und nie übersetzt. **Das gilt nicht mehr.** Auf dieser Maschine
liegt Xcode 27 mit Simulatoren für iOS 26.5 und 27, und `ios/project.yml`
erzeugt das Projekt. Übersetzen, Tests und Starten im Simulator gehen hier
direkt:
```
cd ios && xcodegen generate # nach jeder neuen Datei
xcodebuild -project Certain.xcodeproj -scheme Certain \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' build # oder test
```
**Übersetze nach jeder Stufe selbst, statt dem Nutzer Fehler zu hinterlassen.**
Die kleinen Stufen bleiben trotzdem richtig — nicht wegen der Fehlersuche,
sondern damit jede Stufe für sich benutzbar ist.
---
## 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/`; das Projekt entsteht seit dem 21.10. aus `project.yml` |
| 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
`GET /api/transactions` kennt `account_id`, `category_id`, `uncategorized`,
`type`, `date_from`, `date_to`, `limit` (Standard **5000**), `offset` und seit
Ergänzung B auch `q`, `tag_id`, `subscription_id`. **Keine** wählbare
Sortierung — immer Datum absteigend, dann ID absteigend.
Die Weboberfläche lädt weiter alles und filtert im Browser; sie wurde nicht
umgestellt, weil sie so funktioniert. Die App soll die Filter benutzen.
### 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, **alle vier sind fertig**. Falls du eine fünfte brauchst: 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` ✅
Drei Query-Parameter: `q` (Volltext über `payee`, `description`, `notes`),
`tag_id`, `subscription_id`. Der Tag-Filter arbeitet mit einer Unterabfrage
wie der `uncategorized`-Zweig daneben, nicht mit einem Join — sonst käme eine
Buchung so oft zurück, wie sie Tags trägt.
Zwei Dinge, die die Tests festhalten (`backend/tests/test_buchungssuche.py`,
7 Stück):
- `%` und `_` im Suchtext sind **Zeichen, keine Platzhalter**. Sonst fände
die Eingabe „50%" alles.
- Ein fremder `tag_id` liefert **nichts**, nicht etwa alles. Der Filter
schlägt auf `TransactionTag.owner_id` an.
Grenze: SQLite vergleicht bei LIKE nur ASCII ohne Rücksicht auf Gross- und
Kleinschreibung. „Müller" findet „müller" nicht. Reicht, bis jemand es meldet.
Der lokale Name `q` im Router hiess vorher die Abfrage selbst; die heisst
jetzt `abfrage`, damit der Query-Parameter `q` heissen kann.
### C — `GET /budget/template` liefert `modus` und `zuschlag_cents` ✅
Beide fehlten in der Antwort (`backend/app/routers/budgets.py`), obwohl das
Modell sie führt und `PUT` sie zurückgibt. Jetzt stehen sie in jeder Zeile.
Test: `test_vorlage_nennt_betriebsart_und_zuschlag` in `test_budget.py`.
Das war der stillere der beiden Fehler: Ein Client, der die Vorlage so liest,
zeigt eine berechnete Zeile als feste **und schreibt sie beim nächsten
Speichern auch als feste zurück**. Die Weboberfläche traf es nicht, weil sie
die Betriebsart aus der Monatsansicht nimmt.
### D — Zwei Kopier-Lücken im Budget ✅
`copy_template_to_month` und `copy_month_to_template` in
`backend/app/services/budget.py` übernahmen nur `planned_cents`. Ein
„berechneter" Eintrag fiel beim Kopieren still auf „fest" zurück — mit der
Zahl, die zufällig zuletzt in `planned_cents` stand.
`ensure_customized` nebenan machte es längst richtig; die zwei Kopierer ziehen
jetzt nach. Dabei kam eine Frage auf, die vorher niemand stellen musste: Eine
**berechnete** Zeile gehört auch mit `planned_cents == 0` in die Vorlage, ihre
Zahl steht ja in den Verträgen. Eine **feste** Null bleibt draussen, sonst
füllte sich die Vorlage mit Nullzeilen für jede je berührte Kategorie.
Drei Tests in `test_budget.py` halten beide Richtungen und die Null fest.
---
## 5. Was fertig ist — Stufen 1 bis 5 ✅
### 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, 28 Stück |
| `project.yml` | XcodeGen-Spezifikation, erzeugt `Certain.xcodeproj` |
### 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.)
### Dass es übersetzt, ist inzwischen geprüft
Am 21.10.2026 gebaut, getestet und gestartet (Commit `ffe1870`). Genau **ein**
Übersetzungsfehler in 2600 blind geschriebenen Zeilen: der Vorgabewert
`[String: String] = []` in `CertainClient.schickeDatei` musste `[:]` heissen.
Sonst keine Fehler, keine Warnung im eigenen Code. Die 28 Tests laufen durch,
die App startet im Simulator bei der Serverabfrage.
**Noch nicht geprüft: der Weg gegen ein echtes Backend.** Anmelden, zweiter
Faktor, Marke anlegen, Kontenliste — das lief bisher nur gegen die Modelle,
nicht gegen einen laufenden Server. Siehe „Klartext-HTTP" unten.
### Stufe 2 — Lesen ✅ (Commit `bf437b3`)
Vier neue Reiter, dazu die Modelle und Endpunkte darunter:
| Ordner | Inhalt |
|---|---|
| `Dashboard/` | `DashboardAnsicht` — vier feste Karten, **ein** Abruf |
| `Budget/` | `BudgetAnsicht` — Soll/Ist je Gruppe, blätterbar |
| `Buchungen/` | `BuchungsListe` — Suche und Filter im Server |
| `Abos/` | `AboListe` — Monats- und Jahressumme, Fälligkeit |
| `Modelle/` | `Buchung`, `Budget`, `Abo`, `Kategorie`, `Dashboard` |
Drei Entscheidungen, die man kennen sollte, bevor man daran weiterbaut:
1. **Das Dashboard holt alles in einem Wert** (`Dashboarddaten`). Vier
getrennte Zwischenspeicher könnten offline aus vier verschiedenen
Momenten stammen und sich widersprechen.
2. **Das Nachschlagewerk sitzt im `AppZustand`**, nicht in den Ansichten.
`Nachschlag` hält Kategorien, Schlagwörter und Konten; jede Liste braucht
zu einer ID den Namen, das Symbol und die Farbe.
3. **Die Buchungsliste sucht erst beim Absenden.** Je Tastendruck zu fragen
hiesse, bei „Supermarkt" zehn Abrufe zu schicken, von denen neun niemanden
interessieren.
**Die Übertrags-Paarung steckt in `Buchungszeile.zeilen`** (`Modelle/Buchung.swift`).
Zwei Dinge daran sind leicht falsch zu machen, beide durch Tests festgehalten:
- Zusammengefasst wird **nur, wenn beide Beine in der Liste stehen**. Sonst
liesse ein Kontofilter Zeilen verschwinden, die zu diesem Konto gehören.
- Die Richtung kommt aus `richtung` der Verknüpfung, **nicht aus dem
Vorzeichen** — manche Banken liefern beide Beine positiv.
### Die Modellprüfung ist jetzt ein Test
`backend/scripts/erzeuge_ios_fixture.py` legt eine Wegwerfdatenbank an,
befüllt sie so, dass jedes Feld einmal belegt ist, und schreibt 15 echte
Antworten nach `ios/Tests/Fixtures/antworten.json`. Der Swift-Test
`Serverantworten` entschlüsselt daraus jedes Modell.
**Das ersetzt die Handarbeit, die hier früher beschrieben stand.** Nach jeder
Änderung an einem Schema das Skript neu laufen lassen und die Datei mit
committen:
```
cd backend && .venv/bin/python -m scripts.erzeuge_ios_fixture
```
Wer ein Modell ergänzt, nimmt seinen Endpunkt in `PFADE` auf und schreibt eine
Zeile im Test dazu. Das ist die einzige Prüfung, die einen vertippten
Feldnamen fängt — der übersetzt anstandslos.
### Stufe 3 — Schreiben ✅ (Commit `82db09e`)
Buchungen, Budget-Zeilen, Verträge und Konten lassen sich anlegen, ändern und
löschen.
| Datei | Inhalt |
|---|---|
| `Modelle/Eingaben.swift` | Was zum Server hinausgeht |
| `Bausteine/Formular.swift` | Speichervorgang, Betragsfeld, Auswahlfelder |
| `Buchungen/BuchungFormular.swift` | Anlegen **und** Ändern in einer Maske |
| `Budget/BudgetZeileBearbeiten.swift` | Monat oder Vorlage, dieselbe Maske |
| `Abos/AboFormular.swift`, `Konten/KontoFormular.swift` | ebenso |
**Ein Formular je Sache, nicht zwei.** Anlegen und Ändern teilen sich die
Maske; was sich unterscheidet, steht in einem `Zweck`-Typ. Zwei Masken
nebeneinander wären genau die Stelle, an der sie auseinanderlaufen.
#### Vier Regeln, die man nicht sieht, aber merkt, wenn sie fehlen
1. **Eine Teiländerung schickt nur, was gesetzt ist.** Ginge ein nicht
angefasstes Feld als `null` hinaus, löschte jedes Speichern den Empfänger,
die Notiz und die Kategorie gleich mit. Swift macht das von selbst
(`encodeIfPresent` für Optionals) — der Test dazu bleibt trotzdem stehen,
die Eigenschaft ist zu wichtig für eine ungeprüfte Spracheigenheit.
2. **Eine leere Tag-Liste geht sehr wohl mit.** Sie heisst „alle abnehmen" und
darf nicht mit „nicht angefasst" zusammenfallen.
3. **Ein Übertrag bekommt keine Kategorie.** Das Geld wechselt nur die Tasche.
4. **Bei „berechnet" bleibt `planned_cents` unberührt.** Das Backend liest es
dann nicht, und es zu überschreiben verlöre den Wert, auf den ein
Zurückschalten auf „fest" zurückfallen soll.
Die **Währung eines bestehenden Kontos** lässt sich nicht ändern — jeder
gespeicherte Betrag würde danach etwas anderes bedeuten. Das Backend erlaubt
es; die App nicht.
#### Fehlende Texte findet dieser Befehl
`String(localized:)` mit unbekanntem Schlüssel übersetzt anstandslos und zeigt
dem Nutzer den Schlüssel. Vor dem Committen:
```
cd ios && comm -23 \
<(grep -rhoE 'localized: "[a-zA-Z0-9._]+"' --include="*.swift" . | sed 's/localized: "//; s/"//' | sort -u) \
<(grep -oE '^"[a-zA-Z0-9._]+"' Texte/de.lproj/Localizable.strings | tr -d '"' | sort -u)
```
Gibt er nichts aus, ist nichts offen. (Ein Schlüssel, der nur in einem
`? :` steckt, taucht fälschlich als „fehlend" nicht auf — das ist die eine
Lücke des Befehls.) Dass beide Sprachfassungen dieselben Schlüssel tragen,
prüft ein `diff` der beiden `grep`-Ausgaben.
### Stufe 4 — Arbeitsliste ✅ (Commit `9edf610`)
Ein Reiter „Eingang" mit vier Abschnitten, jeder mit Anzahl im Kopf:
offene Eingangseinträge, Übertragsvorschläge, Gebuchtes ohne Kategorie,
Dubletten. `Arbeitsliste/ArbeitslisteAnsicht.swift`, dazu zwei schmale
Auswahlblätter in `AuswahlBlaetter.swift`.
**Alles in einem Abruf** (`Arbeitsstand`), aus demselben Grund wie beim
Dashboard: Vier getrennte Zwischenspeicher könnten offline aus vier
verschiedenen Momenten stammen.
#### Der 409-Fall sitzt hier, und nur hier
`POST /inbox/{id}/transfer` kann mit 409 antworten. Das ist eine **Frage**,
kein Fehler: Die App zeigt den Hinweis und wiederholt den Aufruf auf Wunsch
mit `trotzdem: true`. `bucheAlsUebertrag` fängt `APIFehler.konflikt` eigens
ab, statt ihn in den allgemeinen Fehlerweg laufen zu lassen. Wer später einen
weiteren 409-Endpunkt anbindet, macht es genauso.
#### Kleinigkeiten, die man sonst zweimal findet
- **Ohne Konto lässt sich nichts buchen** (400 vom Backend). Der Knopf bleibt
grau — und die Zeile sagt „ohne Konto", damit das nicht unerklärt wirkt.
- **Ein Übertrag braucht keine Kategorie.** Daran erkennt man ihn:
`transfer_account_id` ist gesetzt. Ohne diese Prüfung fragte die Liste ewig
nach einer Kategorie, die es nie geben wird.
- **Die Dublettenliste zeigt die Kennung der Bank.** Verschiedene Kennungen
heissen: Die Bank hat sie ausdrücklich als verschiedene Zahlungen gemeldet.
Ohne diese Spalte lässt sich die Frage nicht entscheiden.
#### Ein Backend-Fehler, den die App ans Licht brachte
`/uebertraege/vorschlaege` lieferte jedes Paar **zweimal**, einmal je
Richtung: Was verknüpft wird, fällt über `verbraucht` heraus — ein blosser
Vorschlag nicht. Die Weboberfläche zeigte den Doppeleintrag genauso.
Behoben in `services/uebertraege.py` (`schlage_vor`), mit Test.
### Stufe 5 — Nebenräume ✅ (Commit `d290930`)
Kategorien, Schlagwörter, Bankabruf und der zweite Faktor — alle unter
**Einstellungen**, nicht als eigene Reiter. Man braucht sie selten, und eine
Leiste mit zehn Reitern findet niemand mehr. `Nebenraeume/`.
#### Was hier bewusst nicht geht
- **Den zweiten Faktor abschalten.** Es verlangt das Kontopasswort, und die
App speichert keines. Ein Vollzugang auf diesem Gerät soll den Schutz des
Kontos nicht aufheben können. Dasselbe gilt für neue Rettungscodes.
- **Eine Bank verbinden.** Die Bank leitet fest auf `{base_url}/bank/rueckkehr`
zurück; der Weg führt zwangsläufig über den Browser. Abrufen und Salden
auffrischen gehen von hier.
#### Zwei Eigenheiten, die beim Nachbauen zählen
1. **Der zweite Faktor braucht auf dem Telefon keinen QR-Code.** Die
Authenticator-App liegt daneben — `otpauth_uri` führt per Link direkt
hinein. `qr_png` bleibt ungenutzt; auf dem iPad könnte man es zeigen.
2. **Die Rettungscodes erscheinen genau einmal.** Das Blatt lässt sich nicht
wegwischen (`interactiveDismissDisabled`) und erst schliessen, wenn der
Nutzer bestätigt hat, sie gesichert zu haben. Wer es verliert, braucht
das Kontopasswort am Rechner.
#### Einstellungen schreiben ohne Sichern-Knopf
`PUT /settings/{key}` nimmt je Aufruf **einen** Wert, als Query. Die Bindung
schreibt beim Setzen direkt; ein Formular mit Sichern-Knopf wäre eine
Zwischenstufe, die nichts bringt. Die Werte gehören dem **Konto**, nicht dem
Gerät — das steht auch unter dem Abschnitt, sonst wundert sich jemand, warum
der Browser sich mitändert.
#### Prüfdatei-Tests zählen keine Mengen mehr
Die Saat in `erzeuge_ios_fixture.py` wuchs um Eingangseintrag, Dubletten und
ein Übertragspaar — und die älteren Tests wurden rot, weil sie exakte Anzahlen
prüften. Sie prüfen jetzt Eigenschaften (`>= 2`, „enthält einen Übertrag"),
damit sie das nächste Wachstum überleben. **Daran denken, wenn du die Saat
erweiterst.**
---
## 6. Was als Nächstes dran ist
**Stufe 6 ist dran.** Alle vier Backend-Ergänzungen sind drin; von hier an
reicht die API, wie sie ist.
Was bewusst nicht mitkam: das **Logo** eines Vertrags (Upload und Zuschnitt,
`POST /subscriptions/{id}/logo`), die **Reihenfolge** der Konten
(`PUT /accounts/order`), das **Lösen** einer Übertrags-Verknüpfung
(`DELETE /uebertraege/{link_id}`), die **Transfer-Schlagwörter**
(`/categories/transfer-keywords`) und das **Anwenden** von Regeln auf
Bestehendes (`POST /categories/keywords/anwenden`). Alles am Rechner
bequemer, nichts davon hielt jemanden auf.
Weiter aussen vor, wie von Anfang an entschieden: Bank **verbinden**,
Postfach einrichten, Sicherung und Wiederherstellung, Benutzerverwaltung.
**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.
Stufe 2 hat rund 1800 Zeilen gebraucht, Stufe 3 rund 1200, Stufe 4 rund 900,
Stufe 5 rund 1500; Stufe 6 ist überschaubar, Stufe 7 nochmal zwei bis drei
Wochen.
Der **409-Fall** („nachfragen", mit `trotzdem=true` wiederholbar) ist in
Stufe 4 angekommen und erledigt — siehe dort. In den Stufen 5 bis 7 kommt er
nicht mehr vor.
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
### Klartext-HTTP — der nächste Stolperstein
Das Projekt läuft mit der Voreinstellung von App Transport Security, also
**nur https**. Ein selbstgehosteter Server im Heimnetz unter `http://…` wird
abgewiesen, und `http://localhost` beim Entwickeln ebenso — obwohl
`ServerAnsicht` http ausdrücklich zulässt und ein Test das festhält
(„http bleibt http"). Wer gegen ein lokales Backend prüfen will, braucht
in `project.yml` eine ATS-Ausnahme. Das ist eine Sicherheitsentscheidung des
Nutzers: über Klartext geht die Gerätemarke, ein Vollzugang, mit jeder
Anfrage mit.
### Was beim Übersetzen auffiel
Alles, was hier als verdächtig stand, hat gehalten: `Lader` als generische
`@Observable`-Klasse, der `@MainActor`/Actor-Übergang zwischen `AppZustand`
und `CertainClient`, `Tab` mit `.tabViewStyle(.sidebarAdaptable)`, Swift
Testing statt XCTest. Eine Einschränkung: Das Projekt baut in **Sprachfassung
Swift 5**. Unter Swift 6 greift die strikte Nebenläufigkeitsprüfung, und die
Übergänge sind darauf nicht geschrieben — wer umstellen will, rechnet mit
Arbeit an genau diesen Stellen.
### 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.
- **Neue Swift-Datei?** Danach `cd ios && xcodegen generate`. Die Ordner stehen
einzeln in `project.yml`; ein neuer Ordner will dort eine Zeile.
- **Tests**: `cd backend && .venv/bin/python -m pytest tests/ -q` (auf Windows
`./.venv/Scripts/python.exe`). Python 3.12 im venv; das System-Python kann
nicht bauen. Dauert hier rund 45 Sekunden.
- **Erwartung: alles grün**, 438 Tests. Die zwei Ausfälle, die hier früher als
„vorbekannt rot" standen, waren ein Windows-Problem der alten Maschine
(`WinError 10106` im Subprozess) und laufen auf diesem Rechner durch. Wer
einen roten Test sieht, hat ihn verursacht.
- **Fehlt dem venv ein Modul** (`ModuleNotFoundError`), einmal
`.venv/bin/python -m pip install -r requirements.txt` — es war hier nicht
auf dem Stand von `requirements.txt`.
- **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.
+95
View File
@@ -0,0 +1,95 @@
# XcodeGen-Spezifikation - erzeugt das Xcode-Projekt aus diesen Quellen.
# brew install xcodegen && cd ios && xcodegen generate && open Certain.xcodeproj
# Die Datei ist die Wahrheit, nicht das erzeugte Certain.xcodeproj: wer einen
# Ordner hinzufuegt, laeuft einmal "xcodegen generate" und ist fertig.
name: Certain
options:
bundleIdPrefix: com.scarriffle
# Ohne das baut Xcode eine englische App: Datumsauswahl und Monatsnamen
# erscheinen dann als "10. Dec 2027" statt "10. Dez. 2027".
developmentLanguage: de
deploymentTarget:
iOS: "26.0"
createIntermediateGroups: true
targets:
Certain:
type: application
platform: iOS
sources:
- path: Anmeldung
- path: App
- path: Abos
- path: Arbeitsliste
- path: Bausteine
- path: Buchungen
- path: Budget
- path: Dashboard
- path: Einstellungen
- path: Konten
- path: Modelle
- path: Nebenraeume
- path: Netzwerk
# Deutsch und Englisch, zeilengleich gehalten.
- path: Texte
# Icon-Composer-Buendel (Ordner). Als Datei einhaengen, sonst zerlegt
# XcodeGen es in eine Gruppe und actool findet das Icon nicht.
- path: AppIcon.icon
type: file
buildPhase: resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.certain
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# Signing automatisch; wer mit einem anderen Apple-Konto baut, traegt
# hier seine Team-ID ein (Xcode: Signing & Capabilities -> Team).
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: PP34X97WS3
MARKETING_VERSION: "0.1"
CURRENT_PROJECT_VERSION: "1"
# Nur iPhone, nur Hochformat - am Rechner gibt es die Weboberflaeche.
TARGETED_DEVICE_FAMILY: "1"
SUPPORTED_PLATFORMS: "iphoneos iphonesimulator"
SUPPORTS_MACCATALYST: NO
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.finance"
# Sprachfassung 5: der Code ist nicht auf die strikte
# Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben. Umstellen,
# wenn die Uebergaenge AppZustand <-> CertainClient geprueft sind.
SWIFT_VERSION: "5.0"
# Keine eigene Info.plist pflegen - Xcode erzeugt sie aus diesen Werten.
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleDisplayName: Certain
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait"
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"
# Ohne diesen Text stuerzt die App beim ersten Face-ID-Versuch ab.
INFOPLIST_KEY_NSFaceIDUsageDescription: "Damit entsperrst du Certain, ohne deine PIN zu tippen."
CertainTests:
type: bundle.unit-test
platform: iOS
sources:
# Tests/Fixtures/antworten.json landet als Ressource im Buendel -
# der Test "Serverantworten" liest sie von dort.
- path: Tests
dependencies:
- target: Certain
settings:
base:
SWIFT_VERSION: "5.0"
GENERATE_INFOPLIST_FILE: YES
schemes:
Certain:
build:
targets:
Certain: all
run:
config: Debug
test:
config: Debug
targets:
- CertainTests
+1 -1
View File
@@ -187,7 +187,7 @@ export default function Categories() {
editing,
form: editing
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
: { name: "", kind: "expense", icon: "list", color: "#3f51b5" },
: { name: "", kind: "expense", icon: "list", color: "#3f9c72" },
});
}
function openCategory(groupId, editing = null) {
+1 -1
View File
@@ -17,7 +17,7 @@ import { useToast } from "../toast";
//
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
const STANDARDFARBE = "#3f51b5";
const STANDARDFARBE = "#3f9c72";
export default function Tags() {
const { t } = useTranslation();