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>
476 lines
18 KiB
Swift
476 lines
18 KiB
Swift
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")
|
||
}
|
||
}
|