Compare commits
2
Commits
d04b6b74ad
...
9edf610d59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9edf610d59 | ||
|
|
adbc3e0aa4 |
@@ -310,6 +310,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
|
||||
verbraucht: set[int] = set()
|
||||
verknuepft = 0
|
||||
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):
|
||||
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)
|
||||
if gegen is None:
|
||||
if offen:
|
||||
vorschlaege.append((a, offen[0]))
|
||||
schlage_vor(a, offen[0])
|
||||
continue
|
||||
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
|
||||
if a.category_id is not None or gegen.category_id is not None:
|
||||
vorschlaege.append((a, gegen))
|
||||
schlage_vor(a, gegen)
|
||||
continue
|
||||
if not _hat_hinweis(a, gegen, bezeichner, typen):
|
||||
vorschlaege.append((a, gegen))
|
||||
schlage_vor(a, gegen)
|
||||
continue
|
||||
if nur_vorschlaege:
|
||||
vorschlaege.append((a, gegen))
|
||||
schlage_vor(a, gegen)
|
||||
continue
|
||||
verbinde(db, user, a, gegen, automatisch=True)
|
||||
verbraucht.update((a.id, gegen.id))
|
||||
|
||||
@@ -91,6 +91,37 @@ def _seed(c: TestClient) -> None:
|
||||
params={"planned_cents": 50000})
|
||||
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 = {
|
||||
"version": "/api/version",
|
||||
@@ -108,6 +139,9 @@ PFADE = {
|
||||
"budgetfortschritt": "/api/dashboard/budget-progress",
|
||||
"einnahmenAusgaben": "/api/dashboard/income-expense",
|
||||
"letzteBuchungen": "/api/dashboard/recent",
|
||||
"eingang": "/api/inbox",
|
||||
"dubletten": "/api/transactions/dubletten",
|
||||
"uebertragsvorschlaege": "/api/uebertraege/vorschlaege",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -354,3 +354,30 @@ def test_vorschlag_auch_wenn_die_gegenseite_tage_spaeter_steht(admin):
|
||||
# Bestaetigen kann der Nutzer es trotzdem.
|
||||
assert admin.post("/api/uebertraege/verbinden",
|
||||
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"
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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 ?? ""
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
@@ -69,10 +73,12 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -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 = "<group>";
|
||||
};
|
||||
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */,
|
||||
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */,
|
||||
);
|
||||
path = Arbeitsliste;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */,
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -20,6 +20,7 @@ targets:
|
||||
- path: Anmeldung
|
||||
- path: App
|
||||
- path: Abos
|
||||
- path: Arbeitsliste
|
||||
- path: Bausteine
|
||||
- path: Buchungen
|
||||
- path: Budget
|
||||
|
||||
Reference in New Issue
Block a user