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>
176 lines
5.6 KiB
Swift
176 lines
5.6 KiB
Swift
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"
|
||
}
|
||
}
|