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>
193 lines
7.3 KiB
Swift
193 lines
7.3 KiB
Swift
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
|
||
}
|
||
}
|