From 9edf610d59c4bea317f6dcdb18d3312626ec8b91 Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 21 Oct 2026 21:28:00 +0200 Subject: [PATCH] feat: Vierte Stufe der iOS-App - die Arbeitsliste Ein neuer Reiter "Eingang" mit allem, worueber jemand entscheiden muss. Vier Abschnitte, jeweils mit Anzahl im Kopf: - Zu buchen: offene Eingangseintraege aus Kurzbefehl, Postfach oder Bank. Buchen, als Uebertrag buchen, verwerfen - oder alle auf einmal, bei denen das Konto feststeht. Unklare tragen einen Hinweis ueber den Knoepfen, nicht darunter: Man soll sie ansehen, nicht wegbuchen. - Koennten Uebertraege sein: zwei getrennte Buchungen, die zusammengehoeren koennten. Verbinden oder ablehnen. Hat eine der beiden eine Kategorie, steht dabei, dass sie durchs Verbinden aus dem Budget faellt. - Ohne Kategorie: gebuchtes Geld, das in keiner Budget-Zeile mitzaehlt. Ein Tipp, eine Kategorie - kein Formular mit zwoelf Feldern davor. - Sieht doppelt aus: gleiches Konto, gleicher Tag, gleicher Betrag. Mit der Kennung der Bank je Zeile, denn verschiedene Kennungen heissen, dass die Bank sie als verschiedene Zahlungen gemeldet hat. Alles in einem Abruf. Vier getrennte Zwischenspeicher koennten offline aus vier verschiedenen Momenten stammen und sich widersprechen. Hier sitzt der einzige 409-Fall der App: Beim Buchen als Uebertrag kann das Backend Bedenken haben. Das ist eine Frage, kein Fehler - die App zeigt den Hinweis und wiederholt den Aufruf auf Wunsch mit trotzdem=true. Ihn zu verschlucken waere falsch, ihn als blossen Fehler zu zeigen auch. Zwei Dinge, die die Tests festhalten: Ohne Konto laesst sich nichts buchen (das Backend lehnt mit 400 ab, der Knopf bleibt also grau - und die Zeile sagt auch warum). Und ein Uebertrag braucht keine Kategorie, sonst fragte die Liste ewig nach einer, die es nie geben wird. Die Pruefdatei deckt jetzt auch Eingang, Dubletten und Uebertragsvorschlaege ab. Die aelteren Pruefungen zaehlten exakt und wurden dadurch rot - sie pruefen jetzt Eigenschaften statt Mengen, damit sie das naechste Wachstum ueberleben. 54 Tests gruen. Co-Authored-By: Claude Opus 5 --- ios/App/RootAnsicht.swift | 3 + ios/Arbeitsliste/ArbeitslisteAnsicht.swift | 478 +++++++++++++++++++++ ios/Arbeitsliste/AuswahlBlaetter.swift | 115 +++++ ios/Certain.xcodeproj/project.pbxproj | 20 + ios/Modelle/Arbeitsliste.swift | 215 +++++++++ ios/Netzwerk/Endpunkte.swift | 82 ++++ ios/Tests/Fixtures/antworten.json | 353 +++++++++++++-- ios/Tests/GrundlagenTests.swift | 114 ++++- ios/Texte/de.lproj/Localizable.strings | 32 ++ ios/Texte/en.lproj/Localizable.strings | 32 ++ ios/project.yml | 1 + 11 files changed, 1401 insertions(+), 44 deletions(-) create mode 100644 ios/Arbeitsliste/ArbeitslisteAnsicht.swift create mode 100644 ios/Arbeitsliste/AuswahlBlaetter.swift create mode 100644 ios/Modelle/Arbeitsliste.swift diff --git a/ios/App/RootAnsicht.swift b/ios/App/RootAnsicht.swift index 9858dce..ebc1c00 100644 --- a/ios/App/RootAnsicht.swift +++ b/ios/App/RootAnsicht.swift @@ -51,6 +51,9 @@ struct Hauptnavigation: View { Tab(String(localized: "reiter.buchungen"), systemImage: "list.bullet.rectangle") { NavigationStack { BuchungsListe() } } + Tab(String(localized: "reiter.arbeitsliste"), systemImage: "tray") { + NavigationStack { ArbeitslisteAnsicht() } + } Tab(String(localized: "reiter.abos"), systemImage: "arrow.triangle.2.circlepath") { NavigationStack { AboListe() } } diff --git a/ios/Arbeitsliste/ArbeitslisteAnsicht.swift b/ios/Arbeitsliste/ArbeitslisteAnsicht.swift new file mode 100644 index 0000000..5dd1be4 --- /dev/null +++ b/ios/Arbeitsliste/ArbeitslisteAnsicht.swift @@ -0,0 +1,478 @@ +import SwiftUI + +/// Alles, worüber jemand entscheiden muss, in einem Abruf. +/// +/// Vier Quellen, die nichts voneinander wissen – aber einen gemeinsamen +/// Stand ergeben sollen. Vier getrennte Zwischenspeicher könnten offline aus +/// vier verschiedenen Momenten stammen. +struct Arbeitsstand: Codable, Sendable, Hashable { + let eingang: [Eingangseintrag] + let vorschlaege: Uebertragsvorschlaege + let dubletten: Dublettengruppen + /// Gebuchtes ohne Kategorie – der stillste der vier Posten. Ohne + /// Kategorie zählt eine Ausgabe in keiner Budget-Zeile mit. + let ohneKategorie: [Buchung] + + var istLeer: Bool { + offenerEingang.isEmpty && vorschlaege.vorschlaege.isEmpty + && dubletten.gruppen.isEmpty && ohneKategorie.isEmpty + } + + /// Was im Eingang noch eine Entscheidung braucht. Gebuchtes ist erledigt + /// und steht nur im Weg. + var offenerEingang: [Eingangseintrag] { + eingang.filter { $0.status == .pending || $0.status == .unclear } + } + + var anzahlOffen: Int { + offenerEingang.count + vorschlaege.vorschlaege.count + + dubletten.gruppen.count + ohneKategorie.count + } +} + +extension CertainClient { + func arbeitsstand() async throws -> Arbeitsstand { + async let eingang = self.eingang() + async let vorschlaege = uebertragsvorschlaege() + async let dubletten = self.dubletten() + // Das Backend lässt Überträge hier schon aussen vor und überspringt + // verknüpfte Buchungen – siehe den uncategorized-Zweig im Router. + async let ohneKategorie = buchungen(ohneKategorie: true, grenze: 100) + return try await Arbeitsstand(eingang: eingang, vorschlaege: vorschlaege, + dubletten: dubletten, ohneKategorie: ohneKategorie) + } +} + +/// Die Arbeitsliste: eine Zeile, eine Entscheidung. +/// +/// Fachlich der Teil, der am besten aufs Telefon gehört – man arbeitet ihn +/// zwischendurch ab, nicht am Schreibtisch. +struct ArbeitslisteAnsicht: View { + @Environment(AppZustand.self) private var zustand + @State private var lader: Lader? + @State private var vorgang = Speichervorgang() + @State private var kategorieFuer: Eingangseintrag? + @State private var kategorieFuerBuchung: Buchung? + @State private var uebertragFuer: Eingangseintrag? + @State private var rueckfrage: Rueckfrage? + + /// Was das Backend mit 409 beantwortet hat, samt dem Aufruf, der sich + /// mit `trotzdem` wiederholen lässt. + struct Rueckfrage: Identifiable { + let id = UUID() + let text: String + let nochmal: () async -> Void + } + + var body: some View { + Group { + if let lader { + Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { stand in + liste(stand) + } + } else { + ProgressView() + } + } + .navigationTitle(String(localized: "reiter.arbeitsliste")) + .task { + guard lader == nil, let client = zustand.client else { return } + let neuer = Lader(schluessel: "arbeitsliste") { + try await client.arbeitsstand() + } + lader = neuer + await neuer.lade() + } + .sheet(item: $kategorieFuer) { eintrag in + KategorieWaehlen(titel: eintrag.payee ?? eintrag.quelle, + nachschlag: zustand.nachschlag) { kategorie in + await tu { try await $0.setzeEingangsKategorie(eintrag.id, kategorie: kategorie) } + } + } + .sheet(item: $kategorieFuerBuchung) { buchung in + KategorieWaehlen(titel: buchung.ueberschrift, + nachschlag: zustand.nachschlag, + art: buchung.type == .income ? .income : .expense) { kategorie in + await tu { try await $0.aendereBuchung(buchung.id, + BuchungAendern(categoryId: kategorie)) } + } + } + .sheet(item: $uebertragFuer) { eintrag in + ZielkontoWaehlen(nachschlag: zustand.nachschlag, ausser: eintrag.accountId) { ziel in + await bucheAlsUebertrag(eintrag, ziel: ziel, trotzdem: false) + } + } + .alert(item: $rueckfrage) { frage in + Alert(title: Text(String(localized: "arbeit.rueckfrage")), + message: Text(frage.text), + primaryButton: .default(Text(String(localized: "arbeit.trotzdem"))) { + Task { await frage.nochmal() } + }, + secondaryButton: .cancel(Text(String(localized: "allgemein.abbrechen")))) + } + .alert(String(localized: "fehler.titel"), + isPresented: .constant(vorgang.fehler != nil)) { + Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil } + } message: { + Text(vorgang.fehler ?? "") + } + } + + // MARK: - Liste + + @ViewBuilder + private func liste(_ stand: Arbeitsstand) -> some View { + if stand.istLeer { + LeerAnsicht(titel: String(localized: "arbeit.leerTitel"), + erklaerung: String(localized: "arbeit.leerErklaerung"), + symbol: "checkmark.circle") + } else { + List { + if !stand.offenerEingang.isEmpty { + Section { + ForEach(stand.offenerEingang) { eintrag in + EingangsZeile(eintrag: eintrag, nachschlag: zustand.nachschlag, + buchen: { Task { await buche(eintrag) } }, + uebertrag: { uebertragFuer = eintrag }, + verwerfen: { Task { await verwirf(eintrag) } }) + } + } header: { + Kopf(titel: String(localized: "arbeit.eingang"), + anzahl: stand.offenerEingang.count) + } footer: { + if stand.offenerEingang.contains(where: \.laesstSichBuchen) { + Button(String(localized: "arbeit.alleBuchen")) { + Task { await tu { _ = try await $0.bucheGanzenEingang() } } + } + .disabled(vorgang.laeuft) + } + } + } + + if !stand.vorschlaege.vorschlaege.isEmpty { + Section { + ForEach(stand.vorschlaege.vorschlaege) { paar in + VorschlagsZeile(paar: paar, + verbinden: { Task { await verbinde(paar) } }, + ablehnen: { Task { await lehneAb(paar) } }) + } + } header: { + Kopf(titel: String(localized: "arbeit.uebertraege"), + anzahl: stand.vorschlaege.vorschlaege.count) + } + } + + if !stand.ohneKategorie.isEmpty { + Section { + ForEach(stand.ohneKategorie) { buchung in + Button { + kategorieFuerBuchung = buchung + } label: { + OhneKategorieZeile(buchung: buchung, + nachschlag: zustand.nachschlag) + } + .buttonStyle(.plain) + } + } header: { + Kopf(titel: String(localized: "arbeit.ohneKategorie"), + anzahl: stand.ohneKategorie.count) + } + } + + if !stand.dubletten.gruppen.isEmpty { + Section { + ForEach(stand.dubletten.gruppen) { gruppe in + DublettenZeile(gruppe: gruppe, nachschlag: zustand.nachschlag) { + Task { await behalte(gruppe) } + } + } + } header: { + Kopf(titel: String(localized: "arbeit.dubletten"), + anzahl: stand.dubletten.gruppen.count) + } footer: { + Text(String(localized: "arbeit.dublettenErklaerung")) + } + } + } + .listStyle(.insetGrouped) + .refreshable { await lader?.aktualisiere() } + .disabled(vorgang.laeuft) + } + } + + // MARK: - Entscheidungen + + /// Jede Entscheidung läuft gleich: schreiben, dann neu laden. Die Liste + /// muss danach stimmen – ein erledigter Posten, der stehen bleibt, führt + /// zum zweiten Klick auf dieselbe Sache. + private func tu(_ arbeit: @escaping (CertainClient) async throws -> Void) async { + guard let client = zustand.client else { return } + if await vorgang.fuehreAus({ try await arbeit(client) }) { + await lader?.aktualisiere() + } + } + + private func buche(_ eintrag: Eingangseintrag) async { + await tu { _ = try await $0.bucheEingang(eintrag.id) } + } + + private func verwirf(_ eintrag: Eingangseintrag) async { + await tu { try await $0.verwerfeEingang(eintrag.id) } + } + + /// Der einzige 409-Fall der App: Das Backend hat Bedenken und lässt sich + /// mit `trotzdem` überstimmen. Den Hinweis zu verschlucken wäre falsch, + /// aber ihn als blossen Fehler zu zeigen auch – er ist eine Frage. + private func bucheAlsUebertrag(_ eintrag: Eingangseintrag, ziel: Int, + trotzdem: Bool) async { + guard let client = zustand.client else { return } + do { + _ = try await client.bucheEingangAlsUebertrag(eintrag.id, aufKonto: ziel, + trotzdem: trotzdem) + await lader?.aktualisiere() + } catch APIFehler.konflikt(let text) { + rueckfrage = Rueckfrage(text: text) { + await bucheAlsUebertrag(eintrag, ziel: ziel, trotzdem: true) + } + } catch { + vorgang.fehler = (error as? APIFehler)?.errorDescription + ?? error.localizedDescription + } + } + + private func verbinde(_ paar: Uebertragsvorschlaege.Vorschlag) async { + await tu { try await $0.verbindeUebertrag(paar.a.id, paar.b.id) } + } + + private func lehneAb(_ paar: Uebertragsvorschlaege.Vorschlag) async { + await tu { try await $0.lehneUebertragAb(paar.a.id, paar.b.id) } + } + + private func behalte(_ gruppe: Dublettengruppen.Gruppe) async { + await tu { try await $0.behalteDubletten(gruppe.zeilen.map(\.id)) } + } +} + +// MARK: - Bausteine + +private struct Kopf: View { + let titel: String + let anzahl: Int + + var body: some View { + HStack { + Text(titel) + Spacer() + Text("\(anzahl)") + .monospacedDigit() + .padding(.horizontal, 7) + .padding(.vertical, 1) + .background(.quaternary, in: Capsule()) + } + .textCase(nil) + .font(.footnote) + } +} + +private struct EingangsZeile: View { + let eintrag: Eingangseintrag + let nachschlag: Nachschlag + let buchen: () -> Void + let uebertrag: () -> Void + let verwerfen: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(eintrag.payee ?? String(localized: "buchung.ohneBezeichnung")) + .font(.callout) + .lineLimit(1) + Text(unterzeile) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Text(eintrag.betrag.formatiert()) + .font(.callout.monospacedDigit()) + .foregroundStyle(eintrag.amountCents < 0 ? Color.primary : Color.green) + } + + if eintrag.status == .unclear { + // Die Erkennung war unsicher. Das gehört vor die Knöpfe, nicht + // dahinter – sonst bucht man es weg, ohne es gelesen zu haben. + Label(String(localized: "arbeit.unklarHinweis"), + systemImage: "questionmark.circle") + .font(.caption2) + .foregroundStyle(.orange) + } + + HStack(spacing: 8) { + Button(String(localized: "arbeit.buchen"), action: buchen) + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(!eintrag.laesstSichBuchen) + Button(String(localized: "arbeit.alsUebertrag"), action: uebertrag) + .buttonStyle(.bordered) + .controlSize(.small) + Spacer() + Button(String(localized: "arbeit.verwerfen"), role: .destructive, + action: verwerfen) + .buttonStyle(.bordered) + .controlSize(.small) + } + .buttonStyle(.bordered) + } + .padding(.vertical, 4) + } + + private var unterzeile: String { + var teile = [eintrag.quelle, eintrag.date.formatiert()] + if let konto = nachschlag.konto(eintrag.accountId) { + teile.append(konto.name) + } else { + // Ohne Konto lässt es sich nicht buchen. Das soll dastehen, + // statt dass der Knopf unerklärt grau bleibt. + teile.append(String(localized: "arbeit.ohneKonto")) + } + return teile.joined(separator: " · ") + } +} + +private struct VorschlagsZeile: View { + let paar: Uebertragsvorschlaege.Vorschlag + let verbinden: () -> Void + let ablehnen: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + seite(paar.a) + HStack(spacing: 6) { + Image(systemName: "arrow.down") + .font(.caption2) + .foregroundStyle(.secondary) + Text(paar.a.date.formatiert()) + .font(.caption2) + .foregroundStyle(.secondary) + } + seite(paar.b) + + if paar.hatKategorie { + // Eine zugeordnete Buchung fällt durchs Verbinden aus dem + // Budget. Das soll niemanden überraschen. + Label(String(localized: "arbeit.hatKategorie"), + systemImage: "exclamationmark.circle") + .font(.caption2) + .foregroundStyle(.orange) + } + + HStack(spacing: 8) { + Button(String(localized: "arbeit.verbinden"), action: verbinden) + .buttonStyle(.borderedProminent) + .controlSize(.small) + Button(String(localized: "arbeit.keinUebertrag"), action: ablehnen) + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.vertical, 4) + } + + private func seite(_ seite: Uebertragsvorschlaege.Seite) -> some View { + HStack(spacing: 8) { + Image(systemName: seite.type.symbol) + .font(.caption) + .foregroundStyle(seite.type == .income ? .green : .secondary) + VStack(alignment: .leading, spacing: 1) { + Text(seite.accountName) + .font(.callout) + .lineLimit(1) + Text(seite.bezeichnung) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Text(seite.betrag.formatiert()) + .font(.callout.monospacedDigit()) + } + } +} + +private struct OhneKategorieZeile: View { + let buchung: Buchung + let nachschlag: Nachschlag + + var body: some View { + HStack(spacing: 10) { + Symbolplakette(symbol: "more-horizontal", farbe: "#6b7480", groesse: 32) + VStack(alignment: .leading, spacing: 1) { + Text(buchung.ueberschrift) + .font(.callout) + .lineLimit(1) + Text("\(buchung.date.formatiert()) · \(nachschlag.kontoname(buchung.accountId))") + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Text(buchung.betrag.formatiert(vorzeichen: .ohne)) + .font(.callout.monospacedDigit()) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 2) + } +} + +private struct DublettenZeile: View { + let gruppe: Dublettengruppen.Gruppe + let nachschlag: Nachschlag + let behalten: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(Geldbetrag(cents: gruppe.amountCents, + waehrung: waehrung).formatiert()) + .font(.callout.monospacedDigit()) + Text("· \(gruppe.zeilen.count)×") + .font(.callout) + .foregroundStyle(.secondary) + Spacer() + Text("\(gruppe.datum.formatiert()) · \(nachschlag.kontoname(gruppe.accountId))") + .font(.caption2) + .foregroundStyle(.secondary) + } + + ForEach(gruppe.zeilen) { zeile in + HStack(spacing: 6) { + Text("•").foregroundStyle(.tertiary) + Text(zeile.bezeichnung) + .lineLimit(1) + if let kennung = zeile.kennung { + // Verschiedene Kennungen heissen: Die Bank hat sie + // ausdrücklich als verschiedene Zahlungen gemeldet. + Text(kennung) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Spacer() + Text(zeile.herkunft) + .foregroundStyle(.tertiary) + } + .font(.caption2) + } + + Button(String(localized: "arbeit.istRichtigSo"), action: behalten) + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.vertical, 4) + } + + private var waehrung: String { + nachschlag.konto(gruppe.accountId)?.currency ?? "" + } +} diff --git a/ios/Arbeitsliste/AuswahlBlaetter.swift b/ios/Arbeitsliste/AuswahlBlaetter.swift new file mode 100644 index 0000000..74a04b0 --- /dev/null +++ b/ios/Arbeitsliste/AuswahlBlaetter.swift @@ -0,0 +1,115 @@ +import SwiftUI + +/// Eine Kategorie zuordnen – mehr nicht. +/// +/// Ein eigenes Blatt statt des ganzen Buchungsformulars: In der Arbeitsliste +/// fehlt genau diese eine Angabe, und ein Formular mit zwölf Feldern davor +/// macht aus einer Entscheidung eine Sitzung. +struct KategorieWaehlen: View { + let titel: String + let nachschlag: Nachschlag + var art: Kategorieart = .expense + /// Wird mit der gewählten ID gerufen; schliesst danach von selbst. + let waehle: (Int) async -> Void + + @Environment(\.dismiss) private var schliesse + @State private var laeuft = false + + var body: some View { + NavigationStack { + List { + ForEach(gruppen) { gruppe in + Section(gruppe.name) { + ForEach(gruppe.categories.filter { !$0.isArchived }) { kategorie in + Button { + Task { + laeuft = true + await waehle(kategorie.id) + schliesse() + } + } label: { + HStack(spacing: 10) { + Symbolplakette(symbol: kategorie.icon, + farbe: kategorie.color, groesse: 30) + Text(kategorie.name) + .foregroundStyle(.primary) + Spacer() + } + } + .disabled(laeuft) + } + } + } + } + .navigationTitle(titel) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "allgemein.abbrechen")) { schliesse() } + } + } + } + } + + private var gruppen: [Kategoriegruppe] { + nachschlag.gruppen + .filter { $0.kind == art } + .filter { $0.categories.contains { !$0.isArchived } } + } +} + +/// Auf welches Konto der Übertrag gehen soll. +struct ZielkontoWaehlen: View { + let nachschlag: Nachschlag + /// Das Konto, von dem er kommt – es kann nicht zugleich das Ziel sein. + var ausser: Int? + let waehle: (Int) async -> Void + + @Environment(\.dismiss) private var schliesse + @State private var laeuft = false + + var body: some View { + NavigationStack { + List { + Section { + ForEach(auswaehlbar) { konto in + Button { + Task { + laeuft = true + await waehle(konto.id) + schliesse() + } + } label: { + HStack(spacing: 10) { + Symbolplakette(symbol: konto.icon, farbe: konto.color, + groesse: 30) + VStack(alignment: .leading, spacing: 1) { + Text(konto.name) + .foregroundStyle(.primary) + Text(konto.type.name) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + } + } + .disabled(laeuft) + } + } footer: { + Text(String(localized: "arbeit.zielkontoErklaerung")) + } + } + .navigationTitle(String(localized: "buchung.zielkonto")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "allgemein.abbrechen")) { schliesse() } + } + } + } + } + + private var auswaehlbar: [Konto] { + nachschlag.konten.filter { !$0.isArchived && $0.id != ausser } + } +} diff --git a/ios/Certain.xcodeproj/project.pbxproj b/ios/Certain.xcodeproj/project.pbxproj index 86b7dae..7da11c5 100644 --- a/ios/Certain.xcodeproj/project.pbxproj +++ b/ios/Certain.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */; }; 25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; }; 2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; }; + 2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */; }; + 2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */; }; 3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; }; 3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; }; 402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; }; @@ -33,6 +35,7 @@ 917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A150CAB4967A479B506A997F /* BuchungsListe.swift */; }; 918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; }; A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; }; + AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */; }; B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; }; B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; }; BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; }; @@ -61,6 +64,7 @@ /* Begin PBXFileReference section */ 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = ""; }; 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = ""; }; + 0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Arbeitsliste.swift; sourceTree = ""; }; 0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetZeileBearbeiten.swift; sourceTree = ""; }; 13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = ""; }; 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = ""; }; @@ -69,10 +73,12 @@ 287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = ""; }; 3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = ""; }; 327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = ""; }; + 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuswahlBlaetter.swift; sourceTree = ""; }; 4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = ""; }; 416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; }; 451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = ""; }; 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontoFormular.swift; sourceTree = ""; }; + 4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArbeitslisteAnsicht.swift; sourceTree = ""; }; 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswortWechselAnsicht.swift; sourceTree = ""; }; 5D3341CDA026CDF55D621A8A /* CertainTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CertainTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpunkte.swift; sourceTree = ""; }; @@ -161,6 +167,7 @@ isa = PBXGroup; children = ( E8CBA42A030509615199DD45 /* Abo.swift */, + 0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */, 327787E4869ED7D0C7E9EE63 /* Buchung.swift */, E51DE5BDB909910D586F819F /* Budget.swift */, BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */, @@ -198,6 +205,7 @@ CB7098BB5F4AEFE48BA9BB36 /* Abos */, B4505C6E527A0020B80AFF04 /* Anmeldung */, BEE6BC97D865C2F0C4EB80C4 /* App */, + FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */, 5E720D91F0CF9DBDA975A959 /* Bausteine */, D99101183ED9DA14C8D22722 /* Buchungen */, C4B3E718A814E0236A12EB86 /* Budget */, @@ -271,6 +279,15 @@ path = Netzwerk; sourceTree = ""; }; + FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */ = { + isa = PBXGroup; + children = ( + 4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */, + 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */, + ); + path = Arbeitsliste; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -386,6 +403,9 @@ 04F945FE4BAC298FC242354B /* AboListe.swift in Sources */, E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */, 402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */, + 2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */, + 2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */, + AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */, 4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */, 2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */, 917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */, diff --git a/ios/Modelle/Arbeitsliste.swift b/ios/Modelle/Arbeitsliste.swift new file mode 100644 index 0000000..48f52c0 --- /dev/null +++ b/ios/Modelle/Arbeitsliste.swift @@ -0,0 +1,215 @@ +import Foundation + +/// `InboxItemOut` – eine von aussen erfasste Zahlung, bevor der Kontoauszug +/// sie bestätigt. +/// +/// Quellen: `shortcut` (Kurzbefehl vom Telefon), `mail` (Postfach-Abruf), +/// `bank`. Der Eintrag bleibt auch nach dem Buchen stehen, damit der Abgleich +/// dieselbe Zahlung später wiedererkennt. +struct Eingangseintrag: Codable, Sendable, Identifiable, Hashable { + let id: Int + let source: String + let status: Eingangsstand + let accountId: Int? + let date: Tagesdatum + /// Negativ bei einer Ausgabe – anders als an einer Buchung, wo die + /// Richtung in `type` steckt. + let amountCents: Int + let currency: String + let payee: String? + let note: String? + let categoryId: Int? + let transactionId: Int? + /// Das jeweils **andere** Konto, wenn die Buchung ein Übertrag ist. Ohne + /// das liesse sich ein Übertrag nicht von einer Ausgabe unterscheiden, + /// und die App fragte weiter nach einer Kategorie. + let transferAccountId: Int? + let fxRate: Double? + let counterAmountCents: Int? + let counterCurrency: String? + let createdAt: Zeitpunkt? + + private enum CodingKeys: String, CodingKey { + case id, source, status, date, currency, payee, note + case accountId = "account_id" + case amountCents = "amount_cents" + case categoryId = "category_id" + case transactionId = "transaction_id" + case transferAccountId = "transfer_account_id" + case fxRate = "fx_rate" + case counterAmountCents = "counter_amount_cents" + case counterCurrency = "counter_currency" + case createdAt = "created_at" + } + + var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) } + + var istUebertrag: Bool { transferAccountId != nil } + + /// Buchen geht nur mit Konto – das Backend lehnt es sonst mit 400 ab. + var laesstSichBuchen: Bool { + accountId != nil && (status == .pending || status == .unclear) + } + + /// Fehlt nach dem Buchen noch die Kategorie? Überträge brauchen keine. + var brauchtKategorie: Bool { + status == .booked && categoryId == nil && !istUebertrag + } + + var quelle: String { + switch source { + case "shortcut": return String(localized: "eingang.quelle.kurzbefehl") + case "mail": return String(localized: "eingang.quelle.postfach") + case "bank": return String(localized: "eingang.quelle.bank") + default: return source + } + } +} + +/// `InboxStatus` +enum Eingangsstand: String, Codable, Sendable { + /// Als Buchung übernommen. + case booked + /// Wartet auf Bestätigung. + case pending + /// Die Erkennung war unsicher – erst ansehen. + case unclear + /// Verworfen. Taucht in der Liste nicht mehr auf. + case discarded + + var name: String { + switch self { + case .booked: return String(localized: "eingang.stand.gebucht") + case .pending: return String(localized: "eingang.stand.offen") + case .unclear: return String(localized: "eingang.stand.unklar") + case .discarded: return String(localized: "eingang.stand.verworfen") + } + } +} + +/// `GET /api/uebertraege/vorschlaege` +/// +/// Zwei getrennte Buchungen, die zusammen ein Übertrag sein könnten. Das +/// Backend entscheidet hier **nichts** – beim Hinsehen darf sich nichts ändern. +struct Uebertragsvorschlaege: Codable, Sendable, Hashable { + /// Wie viele beim Suchen von selbst verknüpft wurden. Beim blossen + /// Fragen immer null. + let verknuepft: Int + let vorschlaege: [Vorschlag] + + struct Vorschlag: Codable, Sendable, Identifiable, Hashable { + let a: Seite + let b: Seite + + /// Die Reihenfolge ist nicht bedeutsam – die ID ist es deshalb auch + /// nur als Paar. + var id: String { "\(min(a.id, b.id))-\(max(a.id, b.id))" } + + /// Eine zugeordnete Buchung fällt durchs Verbinden aus dem Budget. + /// Das soll niemanden überraschen. + var hatKategorie: Bool { a.categoryId != nil || b.categoryId != nil } + } + + struct Seite: Codable, Sendable, Identifiable, Hashable { + let id: Int + let date: Tagesdatum + let type: Buchungsart + let amountCents: Int + let currency: String + let accountId: Int + let accountName: String + let payee: String? + let description: String? + let categoryId: Int? + + private enum CodingKeys: String, CodingKey { + case id, date, type, currency, payee, description + case amountCents = "amount_cents" + case accountId = "account_id" + case accountName = "account_name" + case categoryId = "category_id" + } + + var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) } + var bezeichnung: String { + for text in [payee, description] where !(text ?? "").isEmpty { return text! } + return String(localized: "buchung.ohneBezeichnung") + } + } +} + +/// `GET /api/transactions/dubletten` +/// +/// Buchungen, die gleich aussehen. Entschieden wird hier nichts: Drei +/// Aufrundungen über 1.00 am selben Tag sind echt, drei gleiche Einkäufe +/// können es auch sein – das weiss nur der Mensch. +struct Dublettengruppen: Codable, Sendable, Hashable { + let gruppen: [Gruppe] + + struct Gruppe: Codable, Sendable, Identifiable, Hashable { + let accountId: Int + let datum: Tagesdatum + let amountCents: Int + let typ: Buchungsart + let zeilen: [Zeile] + + var id: String { "\(accountId)-\(datum.text)-\(amountCents)-\(typ.rawValue)" } + + private enum CodingKeys: String, CodingKey { + case datum, typ, zeilen + case accountId = "account_id" + case amountCents = "amount_cents" + } + + struct Zeile: Codable, Sendable, Identifiable, Hashable { + let id: Int + let payee: String? + let description: String? + let categoryId: Int? + let herkunft: String + /// Die Kennung der Bank. Tragen zwei Zeilen verschiedene, hat die + /// Bank sie ausdrücklich als verschiedene Zahlungen gemeldet. + let kennung: String? + + private enum CodingKeys: String, CodingKey { + case id, payee, description, herkunft, kennung + case categoryId = "category_id" + } + + var bezeichnung: String { + for text in [payee, description] where !(text ?? "").isEmpty { return text! } + return String(localized: "buchung.ohneBezeichnung") + } + } + } +} + +/// Zwei Buchungen, die zusammengehören – für Verbinden und Ablehnen. +struct Buchungspaar: Encodable, Sendable { + let aId: Int + let bId: Int + + private enum CodingKeys: String, CodingKey { + case aId = "a_id" + case bId = "b_id" + } +} + +/// `POST /api/inbox/{id}/transfer` +struct AlsUebertragBuchen: Encodable, Sendable { + let toAccountId: Int + /// Nur setzen, wenn der Nutzer es nach einer Rückfrage ausdrücklich will. + /// Das Backend antwortet mit 409, wenn es Bedenken hat. + var trotzdem = false + + private enum CodingKeys: String, CodingKey { + case trotzdem + case toAccountId = "to_account_id" + } +} + +/// Was `POST /api/inbox/book-all` zurückmeldet. +struct SammelErgebnis: Codable, Sendable, Hashable { + let gebucht: Int + let uebertraege: Int? +} diff --git a/ios/Netzwerk/Endpunkte.swift b/ios/Netzwerk/Endpunkte.swift index 5b0f2d0..7d12c64 100644 --- a/ios/Netzwerk/Endpunkte.swift +++ b/ios/Netzwerk/Endpunkte.swift @@ -288,4 +288,86 @@ extension CertainClient { func loescheKonto(_ id: Int) async throws { try await schickeStumm(.delete, "accounts/\(id)") } + + // MARK: - Arbeitsliste: Eingang + + func eingang(grenze: Int = 200) async throws -> [Eingangseintrag] { + try await hole("inbox", abfrage: [URLQueryItem(name: "limit", value: String(grenze))]) + } + + /// Macht aus dem Eintrag eine Buchung. Braucht ein Konto – sonst 400. + @discardableResult + func bucheEingang(_ id: Int) async throws -> Eingangseintrag { + try await schicke(.post, "inbox/\(id)/book") + } + + /// Bucht den Eintrag als Kontoübertrag statt als Ausgabe oder Einnahme. + /// + /// Hat das Backend Bedenken, antwortet es mit 409 (`APIFehler.konflikt`). + /// Dann den Nutzer fragen und mit `trotzdem: true` wiederholen – nicht + /// stillschweigend übergehen und nicht einfach aufgeben. + @discardableResult + func bucheEingangAlsUebertrag(_ id: Int, aufKonto: Int, + trotzdem: Bool = false) async throws -> Eingangseintrag { + try await schicke(.post, "inbox/\(id)/transfer", + koerper: AlsUebertragBuchen(toAccountId: aufKonto, + trotzdem: trotzdem)) + } + + /// Alle offenen Einträge buchen, die ein Konto haben. Unklare bleiben + /// liegen – die soll man ansehen, nicht im Stapel durchwinken. + @discardableResult + func bucheGanzenEingang() async throws -> SammelErgebnis { + try await schicke(.post, "inbox/book-all") + } + + /// Verwirft den Eintrag samt der Buchung, die daraus entstanden ist. + func verwerfeEingang(_ id: Int) async throws { + try await schickeStumm(.delete, "inbox/\(id)") + } + + /// Setzt die Kategorie auf dem Eintrag **und** auf der erzeugten Buchung. + @discardableResult + func setzeEingangsKategorie(_ id: Int, kategorie: Int) async throws -> Eingangseintrag { + try await schicke(.post, "inbox/\(id)/category/\(kategorie)") + } + + // MARK: - Arbeitsliste: Überträge + + /// Fragt nur – beim Hinsehen wird nichts verknüpft. + func uebertragsvorschlaege() async throws -> Uebertragsvorschlaege { + try await hole("uebertraege/vorschlaege") + } + + /// Sucht aktiv: Eindeutige Paare mit Übertrags-Hinweis werden dabei + /// verknüpft, der Rest kommt als Vorschlag zurück. + @discardableResult + func sucheUebertraege() async throws -> Uebertragsvorschlaege { + try await schicke(.post, "uebertraege/suchen") + } + + func verbindeUebertrag(_ a: Int, _ b: Int) async throws { + try await schickeStumm(.post, "uebertraege/verbinden", + koerper: Buchungspaar(aId: a, bId: b)) + } + + /// Lehnt einen Vorschlag ab, damit er bei jedem weiteren Suchlauf + /// wegbleibt. + func lehneUebertragAb(_ a: Int, _ b: Int) async throws { + try await schickeStumm(.post, "uebertraege/ignorieren", + koerper: Buchungspaar(aId: a, bId: b)) + } + + // MARK: - Arbeitsliste: Dubletten + + func dubletten() async throws -> Dublettengruppen { + try await hole("transactions/dubletten") + } + + /// „Sieht gleich aus, ist aber richtig so." Die Gruppe verschwindet erst, + /// wenn **alle** ihre Zeilen den Haken tragen. + func behalteDubletten(_ ids: [Int]) async throws { + try await schickeStumm(.post, "transactions/dubletten/behalten", + koerper: ["ids": ids]) + } } diff --git a/ios/Tests/Fixtures/antworten.json b/ios/Tests/Fixtures/antworten.json index f73ab28..8b81610 100644 --- a/ios/Tests/Fixtures/antworten.json +++ b/ios/Tests/Fixtures/antworten.json @@ -13,7 +13,7 @@ "must_change_password": false, "must_change_password_reason": null, "totp_enabled": false, - "created_at": "2026-10-21T18:22:28.427216", + "created_at": "2026-10-21T19:21:28.289884", "mfa_setup_required": false }, "einstellungen": [ @@ -58,8 +58,8 @@ "position": 0, "savings_category_id": null, "identifiers": [], - "balance_cents": 430750, - "balance_base_cents": 430750, + "balance_cents": 419874, + "balance_base_cents": 419874, "bank_balance_cents": null, "bank_balance_at": null, "bank_balance_vergleichbar": false @@ -84,6 +84,27 @@ "bank_balance_cents": null, "bank_balance_at": null, "bank_balance_vergleichbar": false + }, + { + "id": 3, + "name": "Zweitkonto", + "type": "girokonto", + "currency": "EUR", + "icon": "wallet", + "color": "#3f51b5", + "opening_balance_cents": 0, + "opening_date": null, + "credit_limit_cents": null, + "is_locked": false, + "is_archived": false, + "position": 2, + "savings_category_id": null, + "identifiers": [], + "balance_cents": 7500, + "balance_base_cents": 7500, + "bank_balance_cents": null, + "bank_balance_at": null, + "bank_balance_vergleichbar": false } ], "kategoriegruppen": [ @@ -456,6 +477,136 @@ } ], "buchungen": [ + { + "id": 10, + "type": "income", + "date": "2026-10-21", + "time": null, + "account_id": 3, + "to_account_id": null, + "account_currency": "EUR", + "amount_cents": 7500, + "to_amount_cents": null, + "fx_rate": null, + "amount_eur_cents": 7500, + "amount_base_cents": 7500, + "category_id": null, + "budget_treatment": null, + "budget_category_id": null, + "subscription_id": null, + "budget_period": "2026-10", + "budget_period_manuell": false, + "import_batch_id": null, + "herkunft": "manuell", + "payee": "Dauerauftrag", + "description": null, + "notes": null, + "tag_ids": [] + }, + { + "id": 9, + "type": "expense", + "date": "2026-10-21", + "time": null, + "account_id": 1, + "to_account_id": null, + "account_currency": "EUR", + "amount_cents": 7500, + "to_amount_cents": null, + "fx_rate": null, + "amount_eur_cents": 7500, + "amount_base_cents": 7500, + "category_id": null, + "budget_treatment": null, + "budget_category_id": null, + "subscription_id": null, + "budget_period": "2026-10", + "budget_period_manuell": false, + "import_batch_id": null, + "herkunft": "manuell", + "payee": "Dauerauftrag", + "description": null, + "notes": null, + "tag_ids": [] + }, + { + "id": 8, + "type": "expense", + "date": "2026-10-21", + "time": null, + "account_id": 1, + "to_account_id": null, + "account_currency": "EUR", + "amount_cents": 1043, + "to_amount_cents": null, + "fx_rate": null, + "amount_eur_cents": 1043, + "amount_base_cents": 1043, + "category_id": 1, + "budget_treatment": null, + "budget_category_id": null, + "subscription_id": null, + "budget_period": "2026-10", + "budget_period_manuell": false, + "import_batch_id": null, + "herkunft": "manuell", + "payee": "Kiosk", + "description": null, + "notes": null, + "tag_ids": [] + }, + { + "id": 7, + "type": "expense", + "date": "2026-10-21", + "time": null, + "account_id": 1, + "to_account_id": null, + "account_currency": "EUR", + "amount_cents": 1043, + "to_amount_cents": null, + "fx_rate": null, + "amount_eur_cents": 1043, + "amount_base_cents": 1043, + "category_id": 1, + "budget_treatment": null, + "budget_category_id": null, + "subscription_id": null, + "budget_period": "2026-10", + "budget_period_manuell": false, + "import_batch_id": null, + "herkunft": "manuell", + "payee": "Kiosk", + "description": null, + "notes": null, + "tag_ids": [] + }, + { + "id": 6, + "type": "expense", + "date": "2026-10-21", + "time": null, + "account_id": 1, + "to_account_id": null, + "account_currency": "EUR", + "amount_cents": 1290, + "to_amount_cents": null, + "fx_rate": null, + "amount_eur_cents": 1290, + "amount_base_cents": 1290, + "category_id": null, + "budget_treatment": null, + "budget_category_id": null, + "subscription_id": null, + "budget_period": "2026-10", + "budget_period_manuell": false, + "import_batch_id": null, + "herkunft": "shortcut", + "payee": "Baeckerei", + "description": null, + "notes": null, + "tag_ids": [] + }, { "id": 5, "type": "income", @@ -620,7 +771,7 @@ "icon": "landmark", "color": "#3f51b5", "planned_cents": 50000, - "actual_cents": 4250, + "actual_cents": 6336, "over": false, "good": false, "aus_vertraegen_cents": 108, @@ -631,8 +782,8 @@ "icon": "home", "color": "#3f51b5", "planned_cents": 50000, - "actual_cents": 4250, - "delta_cents": 45750, + "actual_cents": 6336, + "delta_cents": 43664, "over": false, "modus": "fest", "zuschlag_cents": 0, @@ -958,7 +1109,7 @@ }, "totals": { "planned_expense": 50000, - "actual_expense": 4250, + "actual_expense": 6336, "planned_income": 0, "received_income": 0 } @@ -1016,8 +1167,8 @@ "icon": "wallet", "color": "#3f51b5", "currency": "EUR", - "balance_cents": 430750, - "balance_base_cents": 430750, + "balance_cents": 419874, + "balance_base_cents": 419874, "archived": false, "locked": false }, @@ -1031,9 +1182,20 @@ "balance_base_cents": 535000, "archived": false, "locked": true + }, + { + "id": 3, + "name": "Zweitkonto", + "icon": "wallet", + "color": "#3f51b5", + "currency": "EUR", + "balance_cents": 7500, + "balance_base_cents": 7500, + "archived": false, + "locked": false } ], - "total": 430750, + "total": 427374, "gebunden": 535000, "currency": "EUR" }, @@ -1043,7 +1205,7 @@ "name": "Fixkosten", "color": "#3f51b5", "planned": 50000, - "actual": 4250, + "actual": 6336, "over": false, "good": false } @@ -1053,14 +1215,74 @@ "group_by": "group" }, "einnahmenAusgaben": { - "income": 350000, - "expense": 4250, - "net": 345750, + "income": 357500, + "expense": 6336, + "net": 351164, "currency": "EUR", "label": "2026-10" }, "letzteBuchungen": { "transactions": [ + { + "id": 10, + "date": "2026-10-21", + "type": "income", + "title": "Dauerauftrag", + "icon": "more-horizontal", + "color": "#6b7480", + "sign": "pos", + "amount_cents": 7500, + "currency": "EUR", + "amount_base_cents": 7500 + }, + { + "id": 9, + "date": "2026-10-21", + "type": "expense", + "title": "Dauerauftrag", + "icon": "more-horizontal", + "color": "#6b7480", + "sign": "neg", + "amount_cents": 7500, + "currency": "EUR", + "amount_base_cents": 7500 + }, + { + "id": 8, + "date": "2026-10-21", + "type": "expense", + "title": "Wohnen", + "icon": "home", + "color": "#3f51b5", + "sign": "neg", + "amount_cents": 1043, + "currency": "EUR", + "amount_base_cents": 1043 + }, + { + "id": 7, + "date": "2026-10-21", + "type": "expense", + "title": "Wohnen", + "icon": "home", + "color": "#3f51b5", + "sign": "neg", + "amount_cents": 1043, + "currency": "EUR", + "amount_base_cents": 1043 + }, + { + "id": 6, + "date": "2026-10-21", + "type": "expense", + "title": "Baeckerei", + "icon": "more-horizontal", + "color": "#6b7480", + "sign": "neg", + "amount_cents": 1290, + "currency": "EUR", + "amount_base_cents": 1290 + }, { "id": 5, "date": "2026-10-21", @@ -1096,32 +1318,87 @@ "amount_cents": 20000, "currency": "EUR", "amount_base_cents": 20000 - }, - { - "id": 2, - "date": "2026-10-21", - "type": "income", - "title": "Gehalt", - "icon": "banknote", - "color": "#2e7d55", - "sign": "pos", - "amount_cents": 350000, - "currency": "EUR", - "amount_base_cents": 350000 - }, - { - "id": 1, - "date": "2026-10-21", - "type": "expense", - "title": "Wohnen", - "icon": "home", - "color": "#3f51b5", - "sign": "neg", - "amount_cents": 4250, - "currency": "EUR", - "amount_base_cents": 4250 } ], "currency": "EUR" + }, + "eingang": [ + { + "id": 1, + "source": "shortcut", + "status": "booked", + "account_id": 1, + "date": "2026-10-21", + "amount_cents": -1290, + "currency": "EUR", + "payee": "Baeckerei", + "note": null, + "category_id": null, + "transaction_id": 6, + "transfer_account_id": null, + "fx_rate": null, + "counter_amount_cents": null, + "counter_currency": null, + "created_at": "2026-10-21T19:21:28.451708" + } + ], + "dubletten": { + "gruppen": [ + { + "account_id": 1, + "datum": "2026-10-21", + "amount_cents": 1043, + "typ": "expense", + "zeilen": [ + { + "id": 7, + "payee": "Kiosk", + "description": null, + "category_id": 1, + "herkunft": "manuell", + "kennung": null + }, + { + "id": 8, + "payee": "Kiosk", + "description": null, + "category_id": 1, + "herkunft": "manuell", + "kennung": null + } + ] + } + ] + }, + "uebertragsvorschlaege": { + "verknuepft": 0, + "vorschlaege": [ + { + "a": { + "id": 9, + "date": "2026-10-21", + "type": "expense", + "amount_cents": 7500, + "currency": "EUR", + "account_id": 1, + "account_name": "Girokonto", + "payee": "Dauerauftrag", + "description": null, + "category_id": null + }, + "b": { + "id": 10, + "date": "2026-10-21", + "type": "income", + "amount_cents": 7500, + "currency": "EUR", + "account_id": 3, + "account_name": "Zweitkonto", + "payee": "Dauerauftrag", + "description": null, + "category_id": null + } + } + ] } } diff --git a/ios/Tests/GrundlagenTests.swift b/ios/Tests/GrundlagenTests.swift index 9a911f7..83bb7ac 100644 --- a/ios/Tests/GrundlagenTests.swift +++ b/ios/Tests/GrundlagenTests.swift @@ -503,7 +503,9 @@ struct ServerantwortenTests { @Test("Konten, Kategorien und Schlagwörter") func stammdaten() throws { let konten = try lies([Konto].self, "konten") - #expect(konten.count == 2) + // Nicht auf eine genaue Zahl pruefen: Die Pruefdatei waechst mit + // jeder Stufe, und ein Test, der dann rot wird, sagt nichts aus. + #expect(konten.count >= 2) #expect(konten.contains { $0.isLocked }) let gruppen = try lies([Kategoriegruppe].self, "kategoriegruppen") @@ -515,7 +517,7 @@ struct ServerantwortenTests { @Test("Buchungen samt Tags, Übertrag und Vertragszuordnung") func buchungen() throws { let alle = try lies([Buchung].self, "buchungen") - #expect(alle.count == 5) + #expect(alle.count >= 5) // Jede Spielart einmal – sonst prüft die Datei die Felder nicht, die // nur in einem Fall belegt sind. #expect(alle.contains { !$0.tagIds.isEmpty }) @@ -531,9 +533,12 @@ struct ServerantwortenTests { let alle = try lies([Buchung].self, "buchungen") let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare) - // Fünf Buchungen, zwei davon ein Paar: vier Zeilen. - #expect(zeilen.count == 4) - #expect(zeilen.filter(\.istUebertrag).count == 2) + // Die Paarliste fuehrt jede Verknuepfung zweimal auf, einmal je + // Richtung - also halb so viele Paare wie Eintraege. Genau so viele + // Zeilen fallen beim Zusammenfassen weg. + #expect(zeilen.count == alle.count - paare.count / 2) + let uebertraege = zeilen.filter(\.istUebertrag) + #expect(!uebertraege.isEmpty) } @Test("Budget-Monat, Vorlage und Verträge") @@ -557,7 +562,7 @@ struct ServerantwortenTests { @Test("Alle vier Dashboard-Karten") func dashboard() throws { let staende = try lies(Kontostaende.self, "kontostaende") - #expect(staende.accounts.count == 2) + #expect(staende.accounts.count >= 2) #expect(staende.gebunden != 0) #expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty) @@ -642,3 +647,100 @@ struct EingabenTests { #expect(Zahlungsrhythmus.allCases.map(\.rawValue) == [1, 2, 3, 6, 12]) } } + +// MARK: - Arbeitsliste + +@Suite("Arbeitsliste") +struct ArbeitslisteTests { + + private func eintrag(stand: String, konto: Int?, kategorie: Int?, + gegenkonto: Int? = nil) throws -> Eingangseintrag { + let roh = """ + {"id": 1, "source": "shortcut", "status": "\(stand)", + "account_id": \(konto.map(String.init) ?? "null"), + "date": "2026-10-15", "amount_cents": -1290, "currency": "EUR", + "payee": "Baeckerei", "note": null, + "category_id": \(kategorie.map(String.init) ?? "null"), + "transaction_id": null, + "transfer_account_id": \(gegenkonto.map(String.init) ?? "null"), + "fx_rate": null, "counter_amount_cents": null, "counter_currency": null, + "created_at": "2026-10-15T14:18:15.780705"} + """ + return try JSONDecoder().decode(Eingangseintrag.self, from: Data(roh.utf8)) + } + + @Test("Ohne Konto laesst sich nichts buchen") + func ohneKontoKeinBuchen() throws { + // Das Backend lehnt es mit 400 ab. Den Knopf trotzdem anzubieten + // hiesse, dem Nutzer eine Fehlermeldung als Antwort zu geben. + #expect(try eintrag(stand: "pending", konto: nil, kategorie: nil) + .laesstSichBuchen == false) + #expect(try eintrag(stand: "pending", konto: 3, kategorie: nil) + .laesstSichBuchen) + } + + @Test("Was schon gebucht ist, steht nicht mehr zum Buchen an") + func gebuchtesIstErledigt() throws { + #expect(try eintrag(stand: "booked", konto: 3, kategorie: 7) + .laesstSichBuchen == false) + // Unklares schon: Es wartet auf einen Blick, nicht auf ein Konto. + #expect(try eintrag(stand: "unclear", konto: 3, kategorie: nil) + .laesstSichBuchen) + } + + @Test("Ein Uebertrag braucht keine Kategorie") + func uebertragBrauchtKeineKategorie() throws { + // Ohne transfer_account_id liesse sich ein Uebertrag nicht von einer + // Ausgabe unterscheiden - und die App fragte ewig nach einer + // Kategorie, die es nie geben wird. + #expect(try eintrag(stand: "booked", konto: 3, kategorie: nil) + .brauchtKategorie) + #expect(try eintrag(stand: "booked", konto: 3, kategorie: nil, gegenkonto: 4) + .brauchtKategorie == false) + } + + @Test("Ein Vorschlag ist dasselbe Paar, egal herum") + func paarIstUngeordnet() throws { + let roh = """ + {"verknuepft": 0, "vorschlaege": [ + {"a": {"id": 9, "date": "2026-10-15", "type": "expense", + "amount_cents": 7500, "currency": "EUR", "account_id": 1, + "account_name": "Giro", "payee": "Dauerauftrag", + "description": null, "category_id": null}, + "b": {"id": 10, "date": "2026-10-15", "type": "income", + "amount_cents": 7500, "currency": "EUR", "account_id": 3, + "account_name": "Zweitkonto", "payee": "Dauerauftrag", + "description": null, "category_id": 4}}]} + """ + let stand = try JSONDecoder().decode(Uebertragsvorschlaege.self, + from: Data(roh.utf8)) + let paar = try #require(stand.vorschlaege.first) + + // Die ID muss unabhaengig von der Reihenfolge sein, sonst zeigt die + // Liste dasselbe Paar als zwei Eintraege. + #expect(paar.id == "9-10") + // Eine zugeordnete Buchung faellt durchs Verbinden aus dem Budget. + #expect(paar.hatKategorie) + } +} + +extension ServerantwortenTests { + + @Test("Eingang, Dubletten und Uebertragsvorschlaege") + func arbeitsliste() throws { + let eingang = try lies([Eingangseintrag].self, "eingang") + #expect(!eingang.isEmpty) + #expect(eingang.allSatisfy { $0.currency.isEmpty == false }) + + let dubletten = try lies(Dublettengruppen.self, "dubletten") + let gruppe = try #require(dubletten.gruppen.first) + #expect(gruppe.zeilen.count >= 2) + + let vorschlaege = try lies(Uebertragsvorschlaege.self, "uebertragsvorschlaege") + #expect(vorschlaege.verknuepft == 0, "beim Fragen darf nichts verknuepft werden") + #expect(vorschlaege.vorschlaege.count == 1) + // Das Paar stand frueher zweimal drin, einmal je Richtung. + let ids = Set(vorschlaege.vorschlaege.map(\.id)) + #expect(ids.count == vorschlaege.vorschlaege.count) + } +} diff --git a/ios/Texte/de.lproj/Localizable.strings b/ios/Texte/de.lproj/Localizable.strings index 9cce3a7..48022a8 100644 --- a/ios/Texte/de.lproj/Localizable.strings +++ b/ios/Texte/de.lproj/Localizable.strings @@ -17,6 +17,7 @@ "reiter.budget" = "Budget"; "reiter.buchungen" = "Buchungen"; "reiter.abos" = "Verträge"; +"reiter.arbeitsliste" = "Eingang"; "reiter.konten" = "Konten"; "reiter.einstellungen" = "Einstellungen"; @@ -239,3 +240,34 @@ "konto.loeschen" = "Konto löschen"; "konto.loeschenFrage" = "Dieses Konto löschen?"; "konto.loeschenErklaerung" = "Das geht nur, wenn keine Buchung mehr daran hängt. Sonst archivieren."; + +/* --- Arbeitsliste --- */ +"arbeit.leerTitel" = "Nichts zu tun"; +"arbeit.leerErklaerung" = "Keine offenen Einträge, keine Übertragsvorschläge, keine Dubletten. So soll es sein."; +"arbeit.eingang" = "Zu buchen"; +"arbeit.uebertraege" = "Könnten Überträge sein"; +"arbeit.ohneKategorie" = "Ohne Kategorie"; +"arbeit.dubletten" = "Sieht doppelt aus"; +"arbeit.dublettenErklaerung" = "Gleiches Konto, gleicher Tag, gleicher Betrag. Ob es wirklich dieselbe Zahlung ist, weisst nur du - verschiedene Kennungen bedeuten, dass die Bank sie als verschieden gemeldet hat."; +"arbeit.buchen" = "Buchen"; +"arbeit.alleBuchen" = "Alle buchen, bei denen das Konto feststeht"; +"arbeit.alsUebertrag" = "Übertrag"; +"arbeit.verwerfen" = "Verwerfen"; +"arbeit.verbinden" = "Verbinden"; +"arbeit.keinUebertrag" = "Kein Übertrag"; +"arbeit.istRichtigSo" = "Ist richtig so"; +"arbeit.unklarHinweis" = "Die Erkennung war unsicher - erst ansehen."; +"arbeit.hatKategorie" = "Eine der beiden hat eine Kategorie. Durchs Verbinden fällt sie aus dem Budget."; +"arbeit.ohneKonto" = "ohne Konto"; +"arbeit.rueckfrage" = "Sicher?"; +"arbeit.trotzdem" = "Trotzdem"; +"arbeit.zielkontoErklaerung" = "Es entsteht eine Buchung, die das Geld von einem Konto aufs andere bewegt. Sie zählt auf beiden Seiten im Saldo und in keiner Auswertung."; + +/* --- Eingang: Quelle und Stand --- */ +"eingang.quelle.kurzbefehl" = "Kurzbefehl"; +"eingang.quelle.postfach" = "Postfach"; +"eingang.quelle.bank" = "Bank"; +"eingang.stand.gebucht" = "gebucht"; +"eingang.stand.offen" = "offen"; +"eingang.stand.unklar" = "unklar"; +"eingang.stand.verworfen" = "verworfen"; diff --git a/ios/Texte/en.lproj/Localizable.strings b/ios/Texte/en.lproj/Localizable.strings index 9cbb85e..2a113d1 100644 --- a/ios/Texte/en.lproj/Localizable.strings +++ b/ios/Texte/en.lproj/Localizable.strings @@ -17,6 +17,7 @@ "reiter.budget" = "Budget"; "reiter.buchungen" = "Transactions"; "reiter.abos" = "Subscriptions"; +"reiter.arbeitsliste" = "Inbox"; "reiter.konten" = "Accounts"; "reiter.einstellungen" = "Settings"; @@ -239,3 +240,34 @@ "konto.loeschen" = "Delete account"; "konto.loeschenFrage" = "Delete this account?"; "konto.loeschenErklaerung" = "Only possible while no transaction is attached. Archive it otherwise."; + +/* --- Inbox / work list --- */ +"arbeit.leerTitel" = "Nothing to do"; +"arbeit.leerErklaerung" = "No pending entries, no transfer suggestions, no duplicates. Just as it should be."; +"arbeit.eingang" = "To book"; +"arbeit.uebertraege" = "Might be transfers"; +"arbeit.ohneKategorie" = "Without a category"; +"arbeit.dubletten" = "Looks duplicated"; +"arbeit.dublettenErklaerung" = "Same account, same day, same amount. Only you know whether it really is the same payment - different reference numbers mean the bank reported them as separate."; +"arbeit.buchen" = "Book"; +"arbeit.alleBuchen" = "Book all that have an account"; +"arbeit.alsUebertrag" = "Transfer"; +"arbeit.verwerfen" = "Discard"; +"arbeit.verbinden" = "Link"; +"arbeit.keinUebertrag" = "Not a transfer"; +"arbeit.istRichtigSo" = "That is correct"; +"arbeit.unklarHinweis" = "The matcher was unsure - have a look first."; +"arbeit.hatKategorie" = "One of the two has a category. Linking takes it out of the budget."; +"arbeit.ohneKonto" = "no account"; +"arbeit.rueckfrage" = "Are you sure?"; +"arbeit.trotzdem" = "Do it anyway"; +"arbeit.zielkontoErklaerung" = "This creates one transaction moving the money from one account to the other. It counts in both balances and in no report."; + +/* --- Inbox: source and status --- */ +"eingang.quelle.kurzbefehl" = "Shortcut"; +"eingang.quelle.postfach" = "Mailbox"; +"eingang.quelle.bank" = "Bank"; +"eingang.stand.gebucht" = "booked"; +"eingang.stand.offen" = "pending"; +"eingang.stand.unklar" = "unclear"; +"eingang.stand.verworfen" = "discarded"; diff --git a/ios/project.yml b/ios/project.yml index 60e27c1..8324cdc 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -20,6 +20,7 @@ targets: - path: Anmeldung - path: App - path: Abos + - path: Arbeitsliste - path: Bausteine - path: Buchungen - path: Budget