Compare commits

...
2 Commits
Author SHA1 Message Date
GuidoandClaude Opus 5 9edf610d59 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 <noreply@anthropic.com>
2026-10-21 21:28:00 +02:00
GuidoandClaude Opus 5 adbc3e0aa4 fix: Ein Uebertragsvorschlag erscheint nur noch einmal
Jedes Paar steht in der Kandidatenliste zweimal, einmal je Richtung. Was
verknuepft wird, faellt ueber "verbraucht" heraus - ein blosser Vorschlag aber
nicht. Also bot /uebertraege/vorschlaege dieselbe Zahlung zweimal zum
Verbinden an, und wer das zweite anklickte, bekam "bereits verknuepft" fuer
etwas, das er gerade selbst verknuepft hatte.

Betrifft auch die Weboberflaeche: Uebertragsvorschlaege.jsx zeigt die Liste
unveraendert an und hatte denselben Doppeleintrag.

Gefunden beim Bauen der Arbeitsliste fuer die iOS-App - die Pruefdatei brachte
den Vorschlag doppelt zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:28:00 +02:00
14 changed files with 1477 additions and 48 deletions
+15 -4
View File
@@ -310,6 +310,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
verbraucht: set[int] = set() verbraucht: set[int] = set()
verknuepft = 0 verknuepft = 0
vorschlaege: list[tuple[Transaction, Transaction]] = [] vorschlaege: list[tuple[Transaction, Transaction]] = []
# Jedes Paar steht in ``kandidaten`` zweimal, einmal je Richtung. Was
# verknüpft wird, fällt über ``verbraucht`` heraus – ein Vorschlag aber
# nicht, und so kam dieselbe Zahlung zweimal zum Verbinden angeboten.
vorgeschlagen: set[tuple[int, int]] = set()
def schlage_vor(a: Transaction, b: Transaction) -> None:
paar = (min(a.id, b.id), max(a.id, b.id))
if paar in vorgeschlagen:
return
vorgeschlagen.add(paar)
vorschlaege.append((a, b))
for a_id in sorted(kandidaten): for a_id in sorted(kandidaten):
if a_id in verbraucht: if a_id in verbraucht:
@@ -321,17 +332,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
gegen = _eindeutig(a, offen, kandidaten, verbraucht) gegen = _eindeutig(a, offen, kandidaten, verbraucht)
if gegen is None: if gegen is None:
if offen: if offen:
vorschlaege.append((a, offen[0])) schlage_vor(a, offen[0])
continue continue
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis. # Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
if a.category_id is not None or gegen.category_id is not None: if a.category_id is not None or gegen.category_id is not None:
vorschlaege.append((a, gegen)) schlage_vor(a, gegen)
continue continue
if not _hat_hinweis(a, gegen, bezeichner, typen): if not _hat_hinweis(a, gegen, bezeichner, typen):
vorschlaege.append((a, gegen)) schlage_vor(a, gegen)
continue continue
if nur_vorschlaege: if nur_vorschlaege:
vorschlaege.append((a, gegen)) schlage_vor(a, gegen)
continue continue
verbinde(db, user, a, gegen, automatisch=True) verbinde(db, user, a, gegen, automatisch=True)
verbraucht.update((a.id, gegen.id)) verbraucht.update((a.id, gegen.id))
+34
View File
@@ -91,6 +91,37 @@ def _seed(c: TestClient) -> None:
params={"planned_cents": 50000}) params={"planned_cents": 50000})
c.post(f"/api/subscriptions/{abo['id']}/buchungen/{kauf['id']}") c.post(f"/api/subscriptions/{abo['id']}/buchungen/{kauf['id']}")
# --- Arbeitsliste -----------------------------------------------------
# Ein Eingangseintrag entsteht nur ueber die Schnellerfassung; von Hand
# legt man keinen an. Also eine Marke ausstellen und damit buchen.
marke = c.post("/api/inbox/tokens",
json={"name": "Fixture", "default_account_id": giro["id"]}).json()
kopf = {"X-Certain-Token": marke["token"]}
c.post("/api/quick/booking", headers=kopf,
json={"amount": "12.90", "payee": "Baeckerei", "date": heute,
"external_id": "fixture-1"})
# Zwei gleich aussehende Buchungen: gleiches Konto, gleicher Tag,
# gleicher krummer Betrag. Genau das meldet /dubletten.
for _ in range(2):
c.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": giro["id"],
"amount_cents": 1043, "payee": "Kiosk", "category_id": ausgabe["id"]})
# Ein Abgang und ein Zugang ueber verschiedene Konten, gleicher Betrag,
# unverknuepft - daraus macht /uebertraege/vorschlaege einen Vorschlag.
# Beide Konten muessen dieselbe Waehrung haben: 75 CHF und 75 EUR sind
# nicht derselbe Betrag, nur dieselbe Zahl (siehe _kandidaten).
zweitgiro = c.post("/api/accounts", json={
"name": "Zweitkonto", "type": "girokonto", "currency": "EUR",
"opening_balance_cents": 0}).json()
c.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": giro["id"],
"amount_cents": 7500, "payee": "Dauerauftrag"})
c.post("/api/transactions", json={
"type": "income", "date": heute, "account_id": zweitgiro["id"],
"amount_cents": 7500, "payee": "Dauerauftrag"})
PFADE = { PFADE = {
"version": "/api/version", "version": "/api/version",
@@ -108,6 +139,9 @@ PFADE = {
"budgetfortschritt": "/api/dashboard/budget-progress", "budgetfortschritt": "/api/dashboard/budget-progress",
"einnahmenAusgaben": "/api/dashboard/income-expense", "einnahmenAusgaben": "/api/dashboard/income-expense",
"letzteBuchungen": "/api/dashboard/recent", "letzteBuchungen": "/api/dashboard/recent",
"eingang": "/api/inbox",
"dubletten": "/api/transactions/dubletten",
"uebertragsvorschlaege": "/api/uebertraege/vorschlaege",
} }
+27
View File
@@ -354,3 +354,30 @@ def test_vorschlag_auch_wenn_die_gegenseite_tage_spaeter_steht(admin):
# Bestaetigen kann der Nutzer es trotzdem. # Bestaetigen kann der Nutzer es trotzdem.
assert admin.post("/api/uebertraege/verbinden", assert admin.post("/api/uebertraege/verbinden",
json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200 json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200
def test_ein_paar_wird_nur_einmal_vorgeschlagen(admin):
"""Jedes Paar steht intern zweimal, einmal je Richtung.
Ohne Entdopplung saehe der Nutzer dieselbe Zahlung zweimal zum Verbinden
angeboten - und wer das zweite anklickt, bekaeme den Fehler "bereits
verknuepft" fuer etwas, das er gerade selbst verknuepft hat.
"""
import secrets
betrag = 100000 + secrets.randbelow(800000)
a = admin.post("/api/accounts", json={
"name": f"A {secrets.token_hex(3)}", "currency": "EUR"}).json()
b = admin.post("/api/accounts", json={
"name": f"B {secrets.token_hex(3)}", "currency": "EUR"}).json()
raus = admin.post("/api/transactions", json={
"type": "expense", "date": "2026-07-14", "account_id": a["id"],
"amount_cents": betrag, "payee": "Umbuchung"}).json()
rein = admin.post("/api/transactions", json={
"type": "income", "date": "2026-07-14", "account_id": b["id"],
"amount_cents": betrag, "payee": "Umbuchung"}).json()
paare = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"]
meins = [p for p in paare
if {p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]}]
assert len(meins) == 1, f"einmal vorschlagen, nicht {len(meins)}-mal"
+3
View File
@@ -51,6 +51,9 @@ struct Hauptnavigation: View {
Tab(String(localized: "reiter.buchungen"), systemImage: "list.bullet.rectangle") { Tab(String(localized: "reiter.buchungen"), systemImage: "list.bullet.rectangle") {
NavigationStack { BuchungsListe() } NavigationStack { BuchungsListe() }
} }
Tab(String(localized: "reiter.arbeitsliste"), systemImage: "tray") {
NavigationStack { ArbeitslisteAnsicht() }
}
Tab(String(localized: "reiter.abos"), systemImage: "arrow.triangle.2.circlepath") { Tab(String(localized: "reiter.abos"), systemImage: "arrow.triangle.2.circlepath") {
NavigationStack { AboListe() } NavigationStack { AboListe() }
} }
+478
View File
@@ -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<Arbeitsstand>?
@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<Arbeitsstand>(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 ?? ""
}
}
+115
View File
@@ -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 }
}
}
+20
View File
@@ -15,6 +15,8 @@
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */; }; 2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */; };
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; }; 25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; };
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.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 */; }; 3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; }; 3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.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 */; }; 917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A150CAB4967A479B506A997F /* BuchungsListe.swift */; };
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; }; 918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; };
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.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 */; }; B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; };
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; }; B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; }; BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
@@ -61,6 +64,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; }; 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = "<group>"; }; 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = "<group>"; };
0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Arbeitsliste.swift; sourceTree = "<group>"; };
0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetZeileBearbeiten.swift; sourceTree = "<group>"; }; 0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetZeileBearbeiten.swift; sourceTree = "<group>"; };
13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = "<group>"; }; 13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = "<group>"; };
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = "<group>"; }; 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = "<group>"; };
@@ -69,10 +73,12 @@
287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = "<group>"; }; 287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = "<group>"; };
3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = "<group>"; }; 3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = "<group>"; };
327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = "<group>"; }; 327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = "<group>"; };
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuswahlBlaetter.swift; sourceTree = "<group>"; };
4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; }; 4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; };
416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = "<group>"; };
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontoFormular.swift; sourceTree = "<group>"; }; 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontoFormular.swift; sourceTree = "<group>"; };
4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArbeitslisteAnsicht.swift; sourceTree = "<group>"; };
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswortWechselAnsicht.swift; sourceTree = "<group>"; }; 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswortWechselAnsicht.swift; sourceTree = "<group>"; };
5D3341CDA026CDF55D621A8A /* CertainTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CertainTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpunkte.swift; sourceTree = "<group>"; };
@@ -161,6 +167,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E8CBA42A030509615199DD45 /* Abo.swift */, E8CBA42A030509615199DD45 /* Abo.swift */,
0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */,
327787E4869ED7D0C7E9EE63 /* Buchung.swift */, 327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
E51DE5BDB909910D586F819F /* Budget.swift */, E51DE5BDB909910D586F819F /* Budget.swift */,
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */, BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
@@ -198,6 +205,7 @@
CB7098BB5F4AEFE48BA9BB36 /* Abos */, CB7098BB5F4AEFE48BA9BB36 /* Abos */,
B4505C6E527A0020B80AFF04 /* Anmeldung */, B4505C6E527A0020B80AFF04 /* Anmeldung */,
BEE6BC97D865C2F0C4EB80C4 /* App */, BEE6BC97D865C2F0C4EB80C4 /* App */,
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */,
5E720D91F0CF9DBDA975A959 /* Bausteine */, 5E720D91F0CF9DBDA975A959 /* Bausteine */,
D99101183ED9DA14C8D22722 /* Buchungen */, D99101183ED9DA14C8D22722 /* Buchungen */,
C4B3E718A814E0236A12EB86 /* Budget */, C4B3E718A814E0236A12EB86 /* Budget */,
@@ -271,6 +279,15 @@
path = Netzwerk; path = Netzwerk;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */ = {
isa = PBXGroup;
children = (
4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */,
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */,
);
path = Arbeitsliste;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -386,6 +403,9 @@
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */, 04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */, E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
402F07ED1E39AB8C3BC6F62B /* AppZustand.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 */, 4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */, 2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */,
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */, 917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
+215
View File
@@ -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?
}
+82
View File
@@ -288,4 +288,86 @@ extension CertainClient {
func loescheKonto(_ id: Int) async throws { func loescheKonto(_ id: Int) async throws {
try await schickeStumm(.delete, "accounts/\(id)") 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])
}
} }
+315 -38
View File
@@ -13,7 +13,7 @@
"must_change_password": false, "must_change_password": false,
"must_change_password_reason": null, "must_change_password_reason": null,
"totp_enabled": false, "totp_enabled": false,
"created_at": "2026-10-21T18:22:28.427216", "created_at": "2026-10-21T19:21:28.289884",
"mfa_setup_required": false "mfa_setup_required": false
}, },
"einstellungen": [ "einstellungen": [
@@ -58,8 +58,8 @@
"position": 0, "position": 0,
"savings_category_id": null, "savings_category_id": null,
"identifiers": [], "identifiers": [],
"balance_cents": 430750, "balance_cents": 419874,
"balance_base_cents": 430750, "balance_base_cents": 419874,
"bank_balance_cents": null, "bank_balance_cents": null,
"bank_balance_at": null, "bank_balance_at": null,
"bank_balance_vergleichbar": false "bank_balance_vergleichbar": false
@@ -84,6 +84,27 @@
"bank_balance_cents": null, "bank_balance_cents": null,
"bank_balance_at": null, "bank_balance_at": null,
"bank_balance_vergleichbar": false "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": [ "kategoriegruppen": [
@@ -456,6 +477,136 @@
} }
], ],
"buchungen": [ "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, "id": 5,
"type": "income", "type": "income",
@@ -620,7 +771,7 @@
"icon": "landmark", "icon": "landmark",
"color": "#3f51b5", "color": "#3f51b5",
"planned_cents": 50000, "planned_cents": 50000,
"actual_cents": 4250, "actual_cents": 6336,
"over": false, "over": false,
"good": false, "good": false,
"aus_vertraegen_cents": 108, "aus_vertraegen_cents": 108,
@@ -631,8 +782,8 @@
"icon": "home", "icon": "home",
"color": "#3f51b5", "color": "#3f51b5",
"planned_cents": 50000, "planned_cents": 50000,
"actual_cents": 4250, "actual_cents": 6336,
"delta_cents": 45750, "delta_cents": 43664,
"over": false, "over": false,
"modus": "fest", "modus": "fest",
"zuschlag_cents": 0, "zuschlag_cents": 0,
@@ -958,7 +1109,7 @@
}, },
"totals": { "totals": {
"planned_expense": 50000, "planned_expense": 50000,
"actual_expense": 4250, "actual_expense": 6336,
"planned_income": 0, "planned_income": 0,
"received_income": 0 "received_income": 0
} }
@@ -1016,8 +1167,8 @@
"icon": "wallet", "icon": "wallet",
"color": "#3f51b5", "color": "#3f51b5",
"currency": "EUR", "currency": "EUR",
"balance_cents": 430750, "balance_cents": 419874,
"balance_base_cents": 430750, "balance_base_cents": 419874,
"archived": false, "archived": false,
"locked": false "locked": false
}, },
@@ -1031,9 +1182,20 @@
"balance_base_cents": 535000, "balance_base_cents": 535000,
"archived": false, "archived": false,
"locked": true "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, "gebunden": 535000,
"currency": "EUR" "currency": "EUR"
}, },
@@ -1043,7 +1205,7 @@
"name": "Fixkosten", "name": "Fixkosten",
"color": "#3f51b5", "color": "#3f51b5",
"planned": 50000, "planned": 50000,
"actual": 4250, "actual": 6336,
"over": false, "over": false,
"good": false "good": false
} }
@@ -1053,14 +1215,74 @@
"group_by": "group" "group_by": "group"
}, },
"einnahmenAusgaben": { "einnahmenAusgaben": {
"income": 350000, "income": 357500,
"expense": 4250, "expense": 6336,
"net": 345750, "net": 351164,
"currency": "EUR", "currency": "EUR",
"label": "2026-10" "label": "2026-10"
}, },
"letzteBuchungen": { "letzteBuchungen": {
"transactions": [ "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, "id": 5,
"date": "2026-10-21", "date": "2026-10-21",
@@ -1096,32 +1318,87 @@
"amount_cents": 20000, "amount_cents": 20000,
"currency": "EUR", "currency": "EUR",
"amount_base_cents": 20000 "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" "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
}
}
]
} }
} }
+108 -6
View File
@@ -503,7 +503,9 @@ struct ServerantwortenTests {
@Test("Konten, Kategorien und Schlagwörter") @Test("Konten, Kategorien und Schlagwörter")
func stammdaten() throws { func stammdaten() throws {
let konten = try lies([Konto].self, "konten") 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 }) #expect(konten.contains { $0.isLocked })
let gruppen = try lies([Kategoriegruppe].self, "kategoriegruppen") let gruppen = try lies([Kategoriegruppe].self, "kategoriegruppen")
@@ -515,7 +517,7 @@ struct ServerantwortenTests {
@Test("Buchungen samt Tags, Übertrag und Vertragszuordnung") @Test("Buchungen samt Tags, Übertrag und Vertragszuordnung")
func buchungen() throws { func buchungen() throws {
let alle = try lies([Buchung].self, "buchungen") 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 // Jede Spielart einmal – sonst prüft die Datei die Felder nicht, die
// nur in einem Fall belegt sind. // nur in einem Fall belegt sind.
#expect(alle.contains { !$0.tagIds.isEmpty }) #expect(alle.contains { !$0.tagIds.isEmpty })
@@ -531,9 +533,12 @@ struct ServerantwortenTests {
let alle = try lies([Buchung].self, "buchungen") let alle = try lies([Buchung].self, "buchungen")
let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare) let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare)
// Fünf Buchungen, zwei davon ein Paar: vier Zeilen. // Die Paarliste fuehrt jede Verknuepfung zweimal auf, einmal je
#expect(zeilen.count == 4) // Richtung - also halb so viele Paare wie Eintraege. Genau so viele
#expect(zeilen.filter(\.istUebertrag).count == 2) // 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") @Test("Budget-Monat, Vorlage und Verträge")
@@ -557,7 +562,7 @@ struct ServerantwortenTests {
@Test("Alle vier Dashboard-Karten") @Test("Alle vier Dashboard-Karten")
func dashboard() throws { func dashboard() throws {
let staende = try lies(Kontostaende.self, "kontostaende") let staende = try lies(Kontostaende.self, "kontostaende")
#expect(staende.accounts.count == 2) #expect(staende.accounts.count >= 2)
#expect(staende.gebunden != 0) #expect(staende.gebunden != 0)
#expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty) #expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty)
@@ -642,3 +647,100 @@ struct EingabenTests {
#expect(Zahlungsrhythmus.allCases.map(\.rawValue) == [1, 2, 3, 6, 12]) #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)
}
}
+32
View File
@@ -17,6 +17,7 @@
"reiter.budget" = "Budget"; "reiter.budget" = "Budget";
"reiter.buchungen" = "Buchungen"; "reiter.buchungen" = "Buchungen";
"reiter.abos" = "Verträge"; "reiter.abos" = "Verträge";
"reiter.arbeitsliste" = "Eingang";
"reiter.konten" = "Konten"; "reiter.konten" = "Konten";
"reiter.einstellungen" = "Einstellungen"; "reiter.einstellungen" = "Einstellungen";
@@ -239,3 +240,34 @@
"konto.loeschen" = "Konto löschen"; "konto.loeschen" = "Konto löschen";
"konto.loeschenFrage" = "Dieses Konto löschen?"; "konto.loeschenFrage" = "Dieses Konto löschen?";
"konto.loeschenErklaerung" = "Das geht nur, wenn keine Buchung mehr daran hängt. Sonst archivieren."; "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";
+32
View File
@@ -17,6 +17,7 @@
"reiter.budget" = "Budget"; "reiter.budget" = "Budget";
"reiter.buchungen" = "Transactions"; "reiter.buchungen" = "Transactions";
"reiter.abos" = "Subscriptions"; "reiter.abos" = "Subscriptions";
"reiter.arbeitsliste" = "Inbox";
"reiter.konten" = "Accounts"; "reiter.konten" = "Accounts";
"reiter.einstellungen" = "Settings"; "reiter.einstellungen" = "Settings";
@@ -239,3 +240,34 @@
"konto.loeschen" = "Delete account"; "konto.loeschen" = "Delete account";
"konto.loeschenFrage" = "Delete this account?"; "konto.loeschenFrage" = "Delete this account?";
"konto.loeschenErklaerung" = "Only possible while no transaction is attached. Archive it otherwise."; "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";
+1
View File
@@ -20,6 +20,7 @@ targets:
- path: Anmeldung - path: Anmeldung
- path: App - path: App
- path: Abos - path: Abos
- path: Arbeitsliste
- path: Bausteine - path: Bausteine
- path: Buchungen - path: Buchungen
- path: Budget - path: Budget