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>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
|
||||
/// `SubscriptionListOut` – die Vertragsliste samt Kopfzahlen.
|
||||
struct Aboliste: Codable, Sendable, Hashable {
|
||||
let subscriptions: [Abo]
|
||||
let summary: Abosummen
|
||||
}
|
||||
|
||||
/// `SubscriptionOut` aus `backend/app/schemas.py`.
|
||||
struct Abo: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let icon: String
|
||||
let color: String
|
||||
/// Der Betrag je Zahlung, in der Währung des Vertrags.
|
||||
let amountCents: Int
|
||||
let currency: String
|
||||
/// 1 = monatlich, 12 = jährlich, 3 = vierteljährlich …
|
||||
let intervalMonths: Int
|
||||
let accountId: Int?
|
||||
let categoryId: Int?
|
||||
let nextDue: Tagesdatum?
|
||||
/// Woran die Erkennung den Vertrag in einer Buchung wiedererkennt.
|
||||
let payeePattern: String?
|
||||
let savingsAccountId: Int?
|
||||
let isActive: Bool
|
||||
let cancelledAt: Tagesdatum?
|
||||
let notes: String?
|
||||
let position: Int
|
||||
/// Auf einen Monat umgelegt, in der Vertragswährung – vom Server gerechnet.
|
||||
let monatlichCents: Int
|
||||
/// Dasselbe in der Hauptwährung. Fehlt der Kurs, fällt es still auf den
|
||||
/// nativen Wert zurück; welche Währungen betroffen sind, sagt
|
||||
/// `Abosummen.ohneKurs`.
|
||||
let monatlichBaseCents: Int
|
||||
let tageBisFaellig: Int?
|
||||
let hasLogo: Bool
|
||||
let logoVersion: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, icon, color, currency, notes, position
|
||||
case amountCents = "amount_cents"
|
||||
case intervalMonths = "interval_months"
|
||||
case accountId = "account_id"
|
||||
case categoryId = "category_id"
|
||||
case nextDue = "next_due"
|
||||
case payeePattern = "payee_pattern"
|
||||
case savingsAccountId = "savings_account_id"
|
||||
case isActive = "is_active"
|
||||
case cancelledAt = "cancelled_at"
|
||||
case monatlichCents = "monatlich_cents"
|
||||
case monatlichBaseCents = "monatlich_base_cents"
|
||||
case tageBisFaellig = "tage_bis_faellig"
|
||||
case hasLogo = "has_logo"
|
||||
case logoVersion = "logo_version"
|
||||
}
|
||||
|
||||
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
|
||||
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
|
||||
|
||||
/// „monatlich", „jährlich", „alle 3 Monate".
|
||||
var rhythmus: String {
|
||||
switch intervalMonths {
|
||||
case 1: return String(localized: "abo.monatlich")
|
||||
case 3: return String(localized: "abo.vierteljaehrlich")
|
||||
case 6: return String(localized: "abo.halbjaehrlich")
|
||||
case 12: return String(localized: "abo.jaehrlich")
|
||||
default: return String(format: String(localized: "abo.alleNMonate"), intervalMonths)
|
||||
}
|
||||
}
|
||||
|
||||
/// Steht die nächste Zahlung in der nächsten Woche an?
|
||||
var istBaldFaellig: Bool {
|
||||
guard isActive, let tage = tageBisFaellig else { return false }
|
||||
return tage <= 7
|
||||
}
|
||||
}
|
||||
|
||||
/// Was am Kopf der Liste steht – die Zahl, wegen der man sie öffnet.
|
||||
struct Abosummen: Codable, Sendable, Hashable {
|
||||
let currency: String
|
||||
let monatlichCents: Int
|
||||
let jaehrlichCents: Int
|
||||
let anzahlAktiv: Int
|
||||
/// Währungen, die sich mangels Kurs nicht umrechnen liessen. Lieber
|
||||
/// nennen als stillschweigend zu klein summieren.
|
||||
let ohneKurs: [String]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case currency
|
||||
case monatlichCents = "monatlich_cents"
|
||||
case jaehrlichCents = "jaehrlich_cents"
|
||||
case anzahlAktiv = "anzahl_aktiv"
|
||||
case ohneKurs = "ohne_kurs"
|
||||
}
|
||||
|
||||
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
|
||||
var jaehrlich: Geldbetrag { Geldbetrag(cents: jaehrlichCents, waehrung: currency) }
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
|
||||
/// `TransactionOut` aus `backend/app/schemas.py`.
|
||||
///
|
||||
/// Drei Beträge an jeder Buchung, und keiner davon wird hier gerechnet:
|
||||
/// `amountCents` steht in der Kontowährung, `amountEurCents` ist der beim
|
||||
/// Speichern eingefrorene EUR-Wert, `amountBaseCents` rechnet der Server bei
|
||||
/// jedem Lesen in die Hauptwährung um. Die App formatiert nur.
|
||||
struct Buchung: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let type: Buchungsart
|
||||
let date: Tagesdatum
|
||||
/// `"14:18:15"` oder nichts. Nur zur Anzeige – gerechnet wird mit dem Tag.
|
||||
let time: String?
|
||||
let accountId: Int
|
||||
let toAccountId: Int?
|
||||
let accountCurrency: String
|
||||
let amountCents: Int
|
||||
let toAmountCents: Int?
|
||||
let fxRate: Double?
|
||||
let amountEurCents: Int
|
||||
let amountBaseCents: Int
|
||||
let categoryId: Int?
|
||||
let budgetTreatment: Budgetwirkung?
|
||||
let budgetCategoryId: Int?
|
||||
/// Der Vertrag, zu dem die Buchung gehört – von der Erkennung gesetzt.
|
||||
let subscriptionId: Int?
|
||||
let budgetPeriod: String
|
||||
let budgetPeriodManuell: Bool
|
||||
let importBatchId: Int?
|
||||
/// bank, mail, shortcut, transfer, csv, manuell.
|
||||
let herkunft: String
|
||||
let payee: String?
|
||||
let description: String?
|
||||
let notes: String?
|
||||
let tagIds: [Int]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, type, date, time, herkunft, payee, description, notes
|
||||
case accountId = "account_id"
|
||||
case toAccountId = "to_account_id"
|
||||
case accountCurrency = "account_currency"
|
||||
case amountCents = "amount_cents"
|
||||
case toAmountCents = "to_amount_cents"
|
||||
case fxRate = "fx_rate"
|
||||
case amountEurCents = "amount_eur_cents"
|
||||
case amountBaseCents = "amount_base_cents"
|
||||
case categoryId = "category_id"
|
||||
case budgetTreatment = "budget_treatment"
|
||||
case budgetCategoryId = "budget_category_id"
|
||||
case subscriptionId = "subscription_id"
|
||||
case budgetPeriod = "budget_period"
|
||||
case budgetPeriodManuell = "budget_period_manuell"
|
||||
case importBatchId = "import_batch_id"
|
||||
case tagIds = "tag_ids"
|
||||
}
|
||||
|
||||
/// In der Kontowährung.
|
||||
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: accountCurrency) }
|
||||
|
||||
/// Was in der Liste als Überschrift steht. Ohne Empfänger nimmt der
|
||||
/// Verwendungszweck seinen Platz ein – eine leere Zeile hilft niemandem.
|
||||
var ueberschrift: String {
|
||||
for text in [payee, description] where !(text ?? "").isEmpty {
|
||||
return text!
|
||||
}
|
||||
return String(localized: "buchung.ohneBezeichnung")
|
||||
}
|
||||
|
||||
/// Die Zeile darunter: Verwendungszweck, falls er nicht schon oben steht.
|
||||
var unterschrift: String? {
|
||||
guard let beschreibung = description, !beschreibung.isEmpty,
|
||||
beschreibung != ueberschrift else { return nil }
|
||||
return beschreibung
|
||||
}
|
||||
|
||||
/// Ob diese Buchung Geld aus dem Haushalt herausbewegt. Überträge nicht –
|
||||
/// sie verschieben nur.
|
||||
var istAusgabe: Bool { type == .expense }
|
||||
}
|
||||
|
||||
/// `TxnType`
|
||||
enum Buchungsart: String, Codable, Sendable, CaseIterable, Identifiable {
|
||||
case expense, income, transfer
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .expense: return String(localized: "buchungsart.ausgabe")
|
||||
case .income: return String(localized: "buchungsart.einnahme")
|
||||
case .transfer: return String(localized: "buchungsart.uebertrag")
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .expense: return "arrow.up.right"
|
||||
case .income: return "arrow.down.left"
|
||||
case .transfer: return "arrow.left.arrow.right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BudgetTreatment` – ob eine Buchung im Budget zählt oder bloss durchläuft.
|
||||
enum Budgetwirkung: String, Codable, Sendable {
|
||||
case budgeted, neutral
|
||||
}
|
||||
|
||||
/// Ein Eintrag aus `GET /api/transactions/links`: die Gegenseite einer
|
||||
/// Buchung, die mit einer zweiten zu einem Übertrag verknüpft ist.
|
||||
///
|
||||
/// Das Gegenkonto steht mit drin, weil die andere Buchung oft gar nicht in
|
||||
/// der gerade gefilterten Liste steht – und „Übertrag" ohne Angabe wohin
|
||||
/// sagt nichts.
|
||||
struct Uebertragsverknuepfung: Codable, Sendable, Hashable {
|
||||
let linkId: Int
|
||||
let otherId: Int
|
||||
/// `"raus"`: Von dieser Buchung geht das Geld weg. `"rein"`: hier kommt es an.
|
||||
let richtung: String
|
||||
let otherAccountId: Int?
|
||||
let automatisch: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case richtung, automatisch
|
||||
case linkId = "link_id"
|
||||
case otherId = "other_id"
|
||||
case otherAccountId = "other_account_id"
|
||||
}
|
||||
|
||||
var gehtRaus: Bool { richtung == "raus" }
|
||||
}
|
||||
|
||||
/// Eine Zeile der Buchungsliste: entweder eine einzelne Buchung oder zwei,
|
||||
/// die zusammen einen Übertrag ergeben.
|
||||
///
|
||||
/// Das Backend kennt zwei Mechanismen nebeneinander: einen Übertrag am Stück
|
||||
/// (eine Buchung mit `toAccountId`) und die Verknüpfung zweier getrennter
|
||||
/// Buchungen, wie die Bank sie liefert. Beide Beine als zwei Zeilen zu zeigen,
|
||||
/// hiesse dasselbe Geld zweimal zeigen. Das Zusammenfassen macht der Client –
|
||||
/// das Backend liefert nur die Paarliste.
|
||||
struct Buchungszeile: Identifiable, Hashable, Sendable {
|
||||
let buchung: Buchung
|
||||
/// Das zweite Bein, wenn es in derselben Liste steht.
|
||||
let gegenstueck: Buchung?
|
||||
/// Die Verknüpfung selbst – auch dann da, wenn das zweite Bein fehlt.
|
||||
let verknuepfung: Uebertragsverknuepfung?
|
||||
|
||||
var id: Int { buchung.id }
|
||||
|
||||
var istUebertrag: Bool { buchung.type == .transfer || verknuepfung != nil }
|
||||
|
||||
/// Von welchem Konto das Geld weggeht.
|
||||
var vonKonto: Int {
|
||||
guard let verknuepfung else { return buchung.accountId }
|
||||
if verknuepfung.gehtRaus { return buchung.accountId }
|
||||
return gegenstueck?.accountId ?? verknuepfung.otherAccountId ?? buchung.accountId
|
||||
}
|
||||
|
||||
/// Auf welchem es ankommt – `nil`, wenn es sich nicht sagen lässt.
|
||||
var nachKonto: Int? {
|
||||
guard let verknuepfung else { return buchung.toAccountId }
|
||||
if verknuepfung.gehtRaus { return gegenstueck?.accountId ?? verknuepfung.otherAccountId }
|
||||
return buchung.accountId
|
||||
}
|
||||
|
||||
/// Baut aus einer Buchungsliste und der Paarliste die Anzeigezeilen.
|
||||
///
|
||||
/// Zusammengefasst wird nur, wenn **beide** Beine in dieser Liste stehen.
|
||||
/// Fehlt eines, bleibt die Zeile für sich – sie trägt dann aber die
|
||||
/// Verknüpfung, damit sie „Übertrag nach …" sagen kann statt gar nichts.
|
||||
/// Ohne diese Vorsicht liesse ein Kontofilter Zeilen verschwinden, die
|
||||
/// sehr wohl zu diesem Konto gehören.
|
||||
static func zeilen(aus buchungen: [Buchung],
|
||||
paare: [String: Uebertragsverknuepfung]) -> [Buchungszeile] {
|
||||
let nachId = Dictionary(buchungen.map { ($0.id, $0) }, uniquingKeysWith: { erstes, _ in erstes })
|
||||
var erledigt = Set<Int>()
|
||||
var ergebnis: [Buchungszeile] = []
|
||||
|
||||
for buchung in buchungen where !erledigt.contains(buchung.id) {
|
||||
let verknuepfung = paare[String(buchung.id)]
|
||||
let anderes = verknuepfung.flatMap { nachId[$0.otherId] }
|
||||
if let anderes {
|
||||
erledigt.insert(anderes.id)
|
||||
}
|
||||
erledigt.insert(buchung.id)
|
||||
ergebnis.append(Buchungszeile(buchung: buchung, gegenstueck: anderes,
|
||||
verknuepfung: verknuepfung))
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
// Die Karten des Dashboards. Anders als im Web ist die Auswahl hier fest –
|
||||
// ein verschiebbares Raster auf Telefonbreite wäre Arbeit ohne Gewinn. Die
|
||||
// vier Endpunkte sind dieselben, die das Web benutzt.
|
||||
|
||||
/// `GET /api/dashboard/balances`
|
||||
struct Kontostaende: Codable, Sendable, Hashable {
|
||||
let accounts: [Kontozeile]
|
||||
/// Verfügbar, ohne gebundene und archivierte Konten.
|
||||
let total: Int
|
||||
/// Was auf gebundenen Konten liegt – Geld, an das man nicht herankommt.
|
||||
let gebunden: Int
|
||||
let currency: String
|
||||
|
||||
var summe: Geldbetrag { Geldbetrag(cents: total, waehrung: currency) }
|
||||
var gebundenBetrag: Geldbetrag { Geldbetrag(cents: gebunden, waehrung: currency) }
|
||||
|
||||
struct Kontozeile: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let icon: String
|
||||
let color: String
|
||||
let currency: String
|
||||
let balanceCents: Int
|
||||
let balanceBaseCents: Int
|
||||
let archived: Bool
|
||||
let locked: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, icon, color, currency, archived, locked
|
||||
case balanceCents = "balance_cents"
|
||||
case balanceBaseCents = "balance_base_cents"
|
||||
}
|
||||
|
||||
var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) }
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/dashboard/budget-progress`
|
||||
struct Budgetfortschritt: Codable, Sendable, Hashable {
|
||||
let rows: [Zeile]
|
||||
let currency: String
|
||||
let label: String
|
||||
let groupBy: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case rows, currency, label
|
||||
case groupBy = "group_by"
|
||||
}
|
||||
|
||||
struct Zeile: Codable, Sendable, Identifiable, Hashable {
|
||||
let name: String
|
||||
let color: String
|
||||
let planned: Int
|
||||
let actual: Int
|
||||
let over: Bool
|
||||
/// Sparen: mehr als geplant ist hier keine Warnung.
|
||||
let good: Bool
|
||||
|
||||
var id: String { name }
|
||||
|
||||
var anteil: Double {
|
||||
guard planned > 0 else { return 0 }
|
||||
return min(1, max(0, Double(actual) / Double(planned)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/dashboard/income-expense`
|
||||
struct EinnahmenAusgaben: Codable, Sendable, Hashable {
|
||||
let income: Int
|
||||
let expense: Int
|
||||
let net: Int
|
||||
let currency: String
|
||||
let label: String
|
||||
|
||||
var einnahmen: Geldbetrag { Geldbetrag(cents: income, waehrung: currency) }
|
||||
var ausgaben: Geldbetrag { Geldbetrag(cents: expense, waehrung: currency) }
|
||||
var uebrig: Geldbetrag { Geldbetrag(cents: net, waehrung: currency) }
|
||||
}
|
||||
|
||||
/// `GET /api/dashboard/recent`
|
||||
struct LetzteBuchungen: Codable, Sendable, Hashable {
|
||||
let transactions: [Zeile]
|
||||
let currency: String
|
||||
|
||||
struct Zeile: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let date: Tagesdatum
|
||||
let type: Buchungsart
|
||||
/// Schon fertig formuliert: die Kategorie, sonst der Empfänger, bei
|
||||
/// einem Übertrag „Konto A → Konto B".
|
||||
let title: String
|
||||
let icon: String
|
||||
let color: String
|
||||
/// `"pos"`, `"neg"` oder nichts – bei einem Übertrag gibt es keine
|
||||
/// Richtung, weil kein Geld den Haushalt verlässt.
|
||||
let sign: String?
|
||||
let amountCents: Int
|
||||
let currency: String
|
||||
let amountBaseCents: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, date, type, title, icon, color, sign, currency
|
||||
case amountCents = "amount_cents"
|
||||
case amountBaseCents = "amount_base_cents"
|
||||
}
|
||||
|
||||
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
|
||||
|
||||
/// Grün bei Einnahme, rot bei Ausgabe, neutral beim Übertrag.
|
||||
var vorzeichen: Vorzeichen {
|
||||
switch sign {
|
||||
case "pos": return .plus
|
||||
case "neg": return .minus
|
||||
default: return .keines
|
||||
}
|
||||
}
|
||||
|
||||
enum Vorzeichen: Sendable { case plus, minus, keines }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
|
||||
/// `CategoryGroupOut` – Kategorien stehen immer in einer Gruppe.
|
||||
struct Kategoriegruppe: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let kind: Kategorieart
|
||||
let icon: String
|
||||
let color: String
|
||||
let position: Int
|
||||
let isBuiltin: Bool
|
||||
let categories: [Kategorie]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, kind, icon, color, position, categories
|
||||
case isBuiltin = "is_builtin"
|
||||
}
|
||||
}
|
||||
|
||||
/// `CategoryOut`
|
||||
struct Kategorie: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let groupId: Int
|
||||
let name: String
|
||||
let type: Kategorieart
|
||||
let icon: String
|
||||
let color: String
|
||||
let position: Int
|
||||
let isBuiltin: Bool
|
||||
let isArchived: Bool
|
||||
/// Zählt in keiner Auswertung mit – etwa „Durchlaufende Posten".
|
||||
let excludedFromStats: Bool
|
||||
/// Eine Ausgabe, die kein Verlust ist: Sparen. Mehr als geplant ist hier
|
||||
/// keine Warnung.
|
||||
let spendingIsGood: Bool
|
||||
let keywordCount: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, type, icon, color, position
|
||||
case groupId = "group_id"
|
||||
case isBuiltin = "is_builtin"
|
||||
case isArchived = "is_archived"
|
||||
case excludedFromStats = "excluded_from_stats"
|
||||
case spendingIsGood = "spending_is_good"
|
||||
case keywordCount = "keyword_count"
|
||||
}
|
||||
}
|
||||
|
||||
/// `CategoryKind`
|
||||
enum Kategorieart: String, Codable, Sendable {
|
||||
case income, expense
|
||||
}
|
||||
|
||||
/// `TagOut` – ein frei vergebenes Etikett, quer zur Kategorie.
|
||||
struct Schlagwort: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let color: String
|
||||
}
|
||||
|
||||
/// Kategorien und Tags, wie die Ansichten sie brauchen: einmal geladen,
|
||||
/// danach als Nachschlagewerk.
|
||||
///
|
||||
/// Fast jede Liste will zu einer ID den Namen, das Symbol und die Farbe.
|
||||
/// Das je Zeile zu holen wäre genau das N+1, das das Backend sich selbst
|
||||
/// verbietet.
|
||||
struct Nachschlag: Codable, Sendable {
|
||||
let gruppen: [Kategoriegruppe]
|
||||
let schlagwoerter: [Schlagwort]
|
||||
let konten: [Konto]
|
||||
|
||||
private let kategorienNachId: [Int: Kategorie]
|
||||
private let schlagwortNachId: [Int: Schlagwort]
|
||||
private let kontoNachId: [Int: Konto]
|
||||
|
||||
init(gruppen: [Kategoriegruppe], schlagwoerter: [Schlagwort], konten: [Konto]) {
|
||||
self.gruppen = gruppen
|
||||
self.schlagwoerter = schlagwoerter
|
||||
self.konten = konten
|
||||
let alle = gruppen.flatMap(\.categories)
|
||||
self.kategorienNachId = Dictionary(alle.map { ($0.id, $0) },
|
||||
uniquingKeysWith: { erstes, _ in erstes })
|
||||
self.schlagwortNachId = Dictionary(schlagwoerter.map { ($0.id, $0) },
|
||||
uniquingKeysWith: { erstes, _ in erstes })
|
||||
self.kontoNachId = Dictionary(konten.map { ($0.id, $0) },
|
||||
uniquingKeysWith: { erstes, _ in erstes })
|
||||
}
|
||||
|
||||
static let leer = Nachschlag(gruppen: [], schlagwoerter: [], konten: [])
|
||||
|
||||
/// Gespeichert werden nur die drei Listen; die Nachschlagetabellen baut
|
||||
/// der Konstruktor daraus neu. Sie mitzuschreiben hiesse, dieselben Daten
|
||||
/// zweimal auf der Platte zu halten.
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case gruppen, schlagwoerter, konten
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let behaelter = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.init(gruppen: try behaelter.decode([Kategoriegruppe].self, forKey: .gruppen),
|
||||
schlagwoerter: try behaelter.decode([Schlagwort].self, forKey: .schlagwoerter),
|
||||
konten: try behaelter.decode([Konto].self, forKey: .konten))
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var behaelter = encoder.container(keyedBy: CodingKeys.self)
|
||||
try behaelter.encode(gruppen, forKey: .gruppen)
|
||||
try behaelter.encode(schlagwoerter, forKey: .schlagwoerter)
|
||||
try behaelter.encode(konten, forKey: .konten)
|
||||
}
|
||||
|
||||
/// Alle Ausgabekategorien, flach – für Auswahllisten.
|
||||
var ausgabekategorien: [Kategorie] {
|
||||
gruppen.filter { $0.kind == .expense }.flatMap(\.categories).filter { !$0.isArchived }
|
||||
}
|
||||
|
||||
func kategorie(_ id: Int?) -> Kategorie? { id.flatMap { kategorienNachId[$0] } }
|
||||
func schlagwort(_ id: Int) -> Schlagwort? { schlagwortNachId[id] }
|
||||
func konto(_ id: Int?) -> Konto? { id.flatMap { kontoNachId[$0] } }
|
||||
|
||||
func kontoname(_ id: Int?) -> String {
|
||||
konto(id)?.name ?? String(localized: "allgemein.unbekannt")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user