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:
@@ -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?
|
||||
}
|
||||
Reference in New Issue
Block a user