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:
Guido
2026-10-21 20:24:06 +02:00
co-authored by Claude Opus 5
parent 58bd504333
commit bf437b3b69
16 changed files with 2252 additions and 2 deletions
+192
View File
@@ -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
}
}