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() 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 } }