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]) } }