feat: Vierte Stufe der iOS-App - die Arbeitsliste

Ein neuer Reiter "Eingang" mit allem, worueber jemand entscheiden muss. Vier
Abschnitte, jeweils mit Anzahl im Kopf:

- Zu buchen: offene Eingangseintraege aus Kurzbefehl, Postfach oder Bank.
  Buchen, als Uebertrag buchen, verwerfen - oder alle auf einmal, bei denen
  das Konto feststeht. Unklare tragen einen Hinweis ueber den Knoepfen, nicht
  darunter: Man soll sie ansehen, nicht wegbuchen.
- Koennten Uebertraege sein: zwei getrennte Buchungen, die zusammengehoeren
  koennten. Verbinden oder ablehnen. Hat eine der beiden eine Kategorie,
  steht dabei, dass sie durchs Verbinden aus dem Budget faellt.
- Ohne Kategorie: gebuchtes Geld, das in keiner Budget-Zeile mitzaehlt. Ein
  Tipp, eine Kategorie - kein Formular mit zwoelf Feldern davor.
- Sieht doppelt aus: gleiches Konto, gleicher Tag, gleicher Betrag. Mit der
  Kennung der Bank je Zeile, denn verschiedene Kennungen heissen, dass die
  Bank sie als verschiedene Zahlungen gemeldet hat.

Alles in einem Abruf. Vier getrennte Zwischenspeicher koennten offline aus
vier verschiedenen Momenten stammen und sich widersprechen.

Hier sitzt der einzige 409-Fall der App: Beim Buchen als Uebertrag kann das
Backend Bedenken haben. Das ist eine Frage, kein Fehler - die App zeigt den
Hinweis und wiederholt den Aufruf auf Wunsch mit trotzdem=true. Ihn zu
verschlucken waere falsch, ihn als blossen Fehler zu zeigen auch.

Zwei Dinge, die die Tests festhalten: Ohne Konto laesst sich nichts buchen
(das Backend lehnt mit 400 ab, der Knopf bleibt also grau - und die Zeile sagt
auch warum). Und ein Uebertrag braucht keine Kategorie, sonst fragte die Liste
ewig nach einer, die es nie geben wird.

Die Pruefdatei deckt jetzt auch Eingang, Dubletten und Uebertragsvorschlaege
ab. Die aelteren Pruefungen zaehlten exakt und wurden dadurch rot - sie
pruefen jetzt Eigenschaften statt Mengen, damit sie das naechste Wachstum
ueberleben. 54 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-10-21 21:28:00 +02:00
co-authored by Claude Opus 5
parent adbc3e0aa4
commit 9edf610d59
11 changed files with 1401 additions and 44 deletions
+215
View File
@@ -0,0 +1,215 @@
import Foundation
/// `InboxItemOut` – eine von aussen erfasste Zahlung, bevor der Kontoauszug
/// sie bestätigt.
///
/// Quellen: `shortcut` (Kurzbefehl vom Telefon), `mail` (Postfach-Abruf),
/// `bank`. Der Eintrag bleibt auch nach dem Buchen stehen, damit der Abgleich
/// dieselbe Zahlung später wiedererkennt.
struct Eingangseintrag: Codable, Sendable, Identifiable, Hashable {
let id: Int
let source: String
let status: Eingangsstand
let accountId: Int?
let date: Tagesdatum
/// Negativ bei einer Ausgabe – anders als an einer Buchung, wo die
/// Richtung in `type` steckt.
let amountCents: Int
let currency: String
let payee: String?
let note: String?
let categoryId: Int?
let transactionId: Int?
/// Das jeweils **andere** Konto, wenn die Buchung ein Übertrag ist. Ohne
/// das liesse sich ein Übertrag nicht von einer Ausgabe unterscheiden,
/// und die App fragte weiter nach einer Kategorie.
let transferAccountId: Int?
let fxRate: Double?
let counterAmountCents: Int?
let counterCurrency: String?
let createdAt: Zeitpunkt?
private enum CodingKeys: String, CodingKey {
case id, source, status, date, currency, payee, note
case accountId = "account_id"
case amountCents = "amount_cents"
case categoryId = "category_id"
case transactionId = "transaction_id"
case transferAccountId = "transfer_account_id"
case fxRate = "fx_rate"
case counterAmountCents = "counter_amount_cents"
case counterCurrency = "counter_currency"
case createdAt = "created_at"
}
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
var istUebertrag: Bool { transferAccountId != nil }
/// Buchen geht nur mit Konto – das Backend lehnt es sonst mit 400 ab.
var laesstSichBuchen: Bool {
accountId != nil && (status == .pending || status == .unclear)
}
/// Fehlt nach dem Buchen noch die Kategorie? Überträge brauchen keine.
var brauchtKategorie: Bool {
status == .booked && categoryId == nil && !istUebertrag
}
var quelle: String {
switch source {
case "shortcut": return String(localized: "eingang.quelle.kurzbefehl")
case "mail": return String(localized: "eingang.quelle.postfach")
case "bank": return String(localized: "eingang.quelle.bank")
default: return source
}
}
}
/// `InboxStatus`
enum Eingangsstand: String, Codable, Sendable {
/// Als Buchung übernommen.
case booked
/// Wartet auf Bestätigung.
case pending
/// Die Erkennung war unsicher – erst ansehen.
case unclear
/// Verworfen. Taucht in der Liste nicht mehr auf.
case discarded
var name: String {
switch self {
case .booked: return String(localized: "eingang.stand.gebucht")
case .pending: return String(localized: "eingang.stand.offen")
case .unclear: return String(localized: "eingang.stand.unklar")
case .discarded: return String(localized: "eingang.stand.verworfen")
}
}
}
/// `GET /api/uebertraege/vorschlaege`
///
/// Zwei getrennte Buchungen, die zusammen ein Übertrag sein könnten. Das
/// Backend entscheidet hier **nichts** – beim Hinsehen darf sich nichts ändern.
struct Uebertragsvorschlaege: Codable, Sendable, Hashable {
/// Wie viele beim Suchen von selbst verknüpft wurden. Beim blossen
/// Fragen immer null.
let verknuepft: Int
let vorschlaege: [Vorschlag]
struct Vorschlag: Codable, Sendable, Identifiable, Hashable {
let a: Seite
let b: Seite
/// Die Reihenfolge ist nicht bedeutsam – die ID ist es deshalb auch
/// nur als Paar.
var id: String { "\(min(a.id, b.id))-\(max(a.id, b.id))" }
/// Eine zugeordnete Buchung fällt durchs Verbinden aus dem Budget.
/// Das soll niemanden überraschen.
var hatKategorie: Bool { a.categoryId != nil || b.categoryId != nil }
}
struct Seite: Codable, Sendable, Identifiable, Hashable {
let id: Int
let date: Tagesdatum
let type: Buchungsart
let amountCents: Int
let currency: String
let accountId: Int
let accountName: String
let payee: String?
let description: String?
let categoryId: Int?
private enum CodingKeys: String, CodingKey {
case id, date, type, currency, payee, description
case amountCents = "amount_cents"
case accountId = "account_id"
case accountName = "account_name"
case categoryId = "category_id"
}
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
var bezeichnung: String {
for text in [payee, description] where !(text ?? "").isEmpty { return text! }
return String(localized: "buchung.ohneBezeichnung")
}
}
}
/// `GET /api/transactions/dubletten`
///
/// Buchungen, die gleich aussehen. Entschieden wird hier nichts: Drei
/// Aufrundungen über 1.00 am selben Tag sind echt, drei gleiche Einkäufe
/// können es auch sein – das weiss nur der Mensch.
struct Dublettengruppen: Codable, Sendable, Hashable {
let gruppen: [Gruppe]
struct Gruppe: Codable, Sendable, Identifiable, Hashable {
let accountId: Int
let datum: Tagesdatum
let amountCents: Int
let typ: Buchungsart
let zeilen: [Zeile]
var id: String { "\(accountId)-\(datum.text)-\(amountCents)-\(typ.rawValue)" }
private enum CodingKeys: String, CodingKey {
case datum, typ, zeilen
case accountId = "account_id"
case amountCents = "amount_cents"
}
struct Zeile: Codable, Sendable, Identifiable, Hashable {
let id: Int
let payee: String?
let description: String?
let categoryId: Int?
let herkunft: String
/// Die Kennung der Bank. Tragen zwei Zeilen verschiedene, hat die
/// Bank sie ausdrücklich als verschiedene Zahlungen gemeldet.
let kennung: String?
private enum CodingKeys: String, CodingKey {
case id, payee, description, herkunft, kennung
case categoryId = "category_id"
}
var bezeichnung: String {
for text in [payee, description] where !(text ?? "").isEmpty { return text! }
return String(localized: "buchung.ohneBezeichnung")
}
}
}
}
/// Zwei Buchungen, die zusammengehören – für Verbinden und Ablehnen.
struct Buchungspaar: Encodable, Sendable {
let aId: Int
let bId: Int
private enum CodingKeys: String, CodingKey {
case aId = "a_id"
case bId = "b_id"
}
}
/// `POST /api/inbox/{id}/transfer`
struct AlsUebertragBuchen: Encodable, Sendable {
let toAccountId: Int
/// Nur setzen, wenn der Nutzer es nach einer Rückfrage ausdrücklich will.
/// Das Backend antwortet mit 409, wenn es Bedenken hat.
var trotzdem = false
private enum CodingKeys: String, CodingKey {
case trotzdem
case toAccountId = "to_account_id"
}
}
/// Was `POST /api/inbox/book-all` zurückmeldet.
struct SammelErgebnis: Codable, Sendable, Hashable {
let gebucht: Int
let uebertraege: Int?
}