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>
374 lines
14 KiB
Swift
374 lines
14 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])
|
||
}
|
||
}
|