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()
|
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))
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */; };
|
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 */,
|
||||||
|
|||||||
@@ -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 {
|
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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user