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>
292 lines
11 KiB
Swift
292 lines
11 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)")
|
||
}
|
||
}
|