Files
GuidoandClaude Opus 5 bf437b3b69 feat: Zweite Stufe der iOS-App - Uebersicht, Budget, Buchungen, Vertraege
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>
2026-10-21 20:24:06 +02:00

176 lines
5.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
}
}