feat: Kreditkarten-Auszuege lesen sich von selbst richtig herum
Auf einem Kartenauszug ist +6,00 eine AUSGABE und -10,00 die Zahlung der Rechnung. Certain kannte diese Leserichtung nicht. Der scheinbare Ausweg "Ein Betrag (immer Ausgabe)" machte auch die Rechnungszahlung und jede Gutschrift zur Ausgabe. Neuer Betragsmodus "signed_inverted". Er dreht Betrag UND Saldospalte: Ein Auszug, der einen Einkauf als +6 fuehrt, schreibt auch den Saldo so – die Schuld steigt. Wer nur den Betrag dreht, bekommt bei jeder einzelnen Zeile eine Abweichung gemeldet, und die einzige Kontrollinstanz des Imports wird wertlos. Dazu gibt es jetzt einen Test. Waehlen muss den Modus niemand: /imports/detect erkennt jetzt auch das KONTO – erkenne_konto konnte das laengst, benutzt hat es nur der Telefon-Kurzbefehl. Ist das erkannte Konto eine Kreditkarte, wird die Leserichtung mitgesetzt. Eng gefasst: nur bei einer Betragsspalte, nur aus "signed" heraus, nur bei sicher erkanntem Konto. Und nie still – in der Pruefansicht steht, dass umgekehrt gelesen wird, und wo man es aendert. Drei Dinge, die dabei auffielen: - Der Abgleich rechnete mit dem Vorzeichen, gebucht wird aber nach Art (abs(Betrag) + Ausgabe/Einnahme). Wer eine Zeile umstellte, pruefte eine Buchung, die so nie entsteht. - Dieselbe Luecke in der Vorschau: Die Betragsspalte faerbte nach dem Rohvorzeichen. Ein Umstellen aenderte sichtbar nichts. - Auf Kartenauszuegen heisst die Empfaengerspalte "Haendler", und viele fuehren die eigene Kontonummer in jeder Zeile mit. Die gewann die Spaltenwahl ueber die Textlaenge – heraus kam eine Liste, in der bei jeder Buchung dasselbe stand. Neu ausserdem eine Summenzeile je Richtung und eine Sammelaktion: Ist eine Datei durchgehend verkehrt herum gelesen, sind alle Zeilen betroffen, und bei 150 Positionen ist Einzelkorrektur keine Moeglichkeit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
38697f828e
commit
98760a4652
@@ -93,6 +93,16 @@ class AmountMode(str, enum.Enum):
|
||||
signed = "signed" # eine Spalte, mit Vorzeichen (− = Ausgabe)
|
||||
debit_credit = "debit_credit" # getrennte Soll-/Haben-Spalten
|
||||
abs_expense = "abs_expense" # Betrag ohne Vorzeichen, immer Ausgabe
|
||||
# Eine Spalte mit Vorzeichen, nur andersherum: + = Ausgabe.
|
||||
#
|
||||
# So fuehren Kreditkarten ihre Auszuege, und das ist aus ihrer Sicht
|
||||
# richtig: Ein Einkauf erhoeht die Schuld, die Rechnungszahlung senkt sie.
|
||||
# Gemeint ist derselbe Vorgang wie bei "signed", nur aus der anderen
|
||||
# Richtung erzaehlt.
|
||||
#
|
||||
# abs_expense ist dafuer KEIN Ersatz – es machte auch die
|
||||
# Rechnungszahlung und jede Gutschrift zur Ausgabe.
|
||||
signed_inverted = "signed_inverted"
|
||||
|
||||
|
||||
class MatchField(str, enum.Enum):
|
||||
|
||||
@@ -9,7 +9,8 @@ from ..database import get_db
|
||||
from ..deps import get_current_user, get_owned
|
||||
from fastapi.responses import Response
|
||||
|
||||
from ..models import PendingUpload, Account, AmountMode, ImportBatch, ImportStatus, ImportTemplate, User
|
||||
from ..models import (PendingUpload, Account, AccountType, AmountMode, ImportBatch,
|
||||
ImportStatus, ImportTemplate, User)
|
||||
from ..schemas import (
|
||||
ImportBatchOut,
|
||||
ImportCommitIn,
|
||||
@@ -94,11 +95,65 @@ def _transient(user_id: int, **fields) -> ImportTemplate:
|
||||
async def detect_format(
|
||||
file: UploadFile = File(...),
|
||||
encoding: str = Form("utf-8"),
|
||||
_: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Format der Datei erkennen, damit niemand mehr Spalten von Hand zuordnet."""
|
||||
"""Format UND Konto der Datei erkennen.
|
||||
|
||||
Das Konto gehoerte von Anfang an hierher: Es ist die gefaehrlichste
|
||||
Entscheidung des ganzen Imports – ein Auszug im falschen Konto verfaelscht
|
||||
zwei Salden auf einmal – und ``erkenne_konto`` konnte sie laengst. Benutzt
|
||||
hat die Funktion bisher nur der Telefon-Kurzbefehl.
|
||||
|
||||
``erkenne_konto`` raet nicht: Bleibt es mehrdeutig, kommt ``None`` zurueck
|
||||
und der Nutzer waehlt wie bisher.
|
||||
"""
|
||||
daten = await file.read()
|
||||
return csv_import.erkenne_format(daten, encoding)
|
||||
erkannt = csv_import.erkenne_format(daten, encoding)
|
||||
|
||||
konto = csv_import.erkenne_konto(db, user, daten, erkannt)
|
||||
erkannt["konto"] = konto
|
||||
|
||||
acc_id = konto.get("account_id")
|
||||
if acc_id:
|
||||
acc = db.get(Account, acc_id)
|
||||
if acc is not None and acc.owner_id == user.id:
|
||||
_kreditkarten_regel(erkannt, acc)
|
||||
return erkannt
|
||||
|
||||
|
||||
def _kreditkarten_regel(erkannt: dict, acc: Account) -> None:
|
||||
"""Bei einer Kreditkarte ist + eine Ausgabe.
|
||||
|
||||
Die Regel steht serverseitig und an EINER Stelle. Im Frontend gebaut
|
||||
zerbraeche sie an der Reihenfolge: ``setAccountId`` und die Formaterkennung
|
||||
laufen dort im selben Durchgang, die Regel laese also noch den alten
|
||||
Zustand.
|
||||
|
||||
Eng gefasst, mit Absicht:
|
||||
|
||||
- Nur ``signed``. Bei getrennten Soll-/Haben-Spalten benennen die Spalten
|
||||
die Richtung selbst, und ``abs_expense`` wurde bewusst gewaehlt.
|
||||
- Nur mit einer Betragsspalte.
|
||||
- Nur, wenn das Konto sicher erkannt wurde – sonst raet man zweimal.
|
||||
|
||||
Umgeschaltet, nicht erzwungen: Der Modus steht unter „Format" und laesst
|
||||
sich aendern. Und in der Pruefansicht steht, dass umgekehrt gelesen wird –
|
||||
eine stille Umkehr waere schlimmer als gar keine.
|
||||
"""
|
||||
if acc.type != AccountType.kreditkarte or not erkannt.get("ok"):
|
||||
return
|
||||
config = erkannt.get("config") or {}
|
||||
if config.get("amount_mode") != AmountMode.signed.value:
|
||||
return
|
||||
if (config.get("column_map") or {}).get("amount") is None:
|
||||
return
|
||||
config["amount_mode"] = AmountMode.signed_inverted.value
|
||||
erkannt["kreditkarte"] = True
|
||||
erkannt["begruendung"] = (
|
||||
(erkannt.get("begruendung") or "")
|
||||
+ f" · Kreditkarte „{acc.name}“: + im Auszug = Ausgabe"
|
||||
).strip(" ·")
|
||||
|
||||
|
||||
@router.post("/raw")
|
||||
|
||||
@@ -112,8 +112,11 @@ KOPF_HINWEISE = {
|
||||
"date": ("buchung", "booked", "datum", "date", "valuta"),
|
||||
"amount": ("betrag", "amount", "umsatz", "credit", "debit"),
|
||||
"balance": ("saldo", "balance", "kontostand"),
|
||||
# „haendler"/„merchant" kommen von Kreditkarten – dort heisst die Spalte
|
||||
# praktisch nie „Empfaenger". Beide Schreibweisen, weil normalize_text nur
|
||||
# kleinschreibt und Umlaute stehen laesst.
|
||||
"payee": ("empfaenger", "empfänger", "partner", "beschreibung", "description",
|
||||
"text", "name", "auftraggeber"),
|
||||
"text", "name", "auftraggeber", "haendler", "händler", "merchant"),
|
||||
"purpose": ("zweck", "reference", "verwendung", "mitteilung", "referenz"),
|
||||
"counterparty_iban": ("iban",),
|
||||
}
|
||||
@@ -245,9 +248,35 @@ def erkenne_format(data: bytes, encoding: str = "utf-8") -> dict:
|
||||
saldo_i = next((i for i in geldspalten
|
||||
if i != betrag_i and _kopf_passt(kopf[i], "balance")), None)
|
||||
|
||||
# Kontonummern sind kein Empfaenger. Viele Auszuege – Kreditkarten fast
|
||||
# immer – fuehren die eigene IBAN in jeder Zeile; sie ist laenger als jeder
|
||||
# Haendlername und gewaenne die Spaltenwahl allein ueber die Textlaenge.
|
||||
# Herausgekommen waere eine Liste, in der bei jeder Buchung dasselbe steht.
|
||||
# Zwei Bedingungen, beide noetig:
|
||||
#
|
||||
# 1. In fast jeder Zeile steht DASSELBE. Was sich nicht von Buchung zu
|
||||
# Buchung aendert, sagt ueber die einzelne Buchung nichts aus.
|
||||
# 2. Es sieht aus wie eine Kontonummer: zusammenhaengend, alphanumerisch,
|
||||
# mindestens 15 Zeichen, mit Ziffern.
|
||||
#
|
||||
# Die erste Bedingung allein waere zu scharf (ein kurzer Auszug kann
|
||||
# denselben Haendler mehrfach enthalten), die zweite allein auch: Ein
|
||||
# Verwendungszweck wie „Rechnung 12345 vom 01.02." ergaebe ohne Trennzeichen
|
||||
# ebenfalls eine lange Zeichenkette.
|
||||
def _ist_kontonummer(i: int) -> bool:
|
||||
werte = _spaltenwerte(zeilen, i)
|
||||
if not werte:
|
||||
return False
|
||||
haeufigste = max(Counter(werte).values())
|
||||
if haeufigste < 0.8 * len(werte):
|
||||
return False
|
||||
wert = werte[0].replace(" ", "")
|
||||
return len(wert) >= 15 and wert.isalnum() and any(c.isdigit() for c in wert)
|
||||
|
||||
text_spalten = sorted(
|
||||
((i, sum(len(w) for w in _spaltenwerte(zeilen, i)) / max(1, len(zeilen)))
|
||||
for i in range(len(kopf)) if i not in (datum_i, betrag_i, saldo_i)),
|
||||
for i in range(len(kopf))
|
||||
if i not in (datum_i, betrag_i, saldo_i) and not _ist_kontonummer(i)),
|
||||
key=lambda x: -x[1],
|
||||
)
|
||||
lange = [i for i, laenge in text_spalten if laenge >= 3]
|
||||
@@ -582,7 +611,14 @@ def state_is_bookable(state: str | None) -> bool:
|
||||
|
||||
|
||||
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
|
||||
"""Zerlegt die Datei anhand des Templates in rohe Kandidaten-Zeilen."""
|
||||
"""Zerlegt die Datei anhand des Templates in rohe Kandidaten-Zeilen.
|
||||
|
||||
Nur fuer den DATEI-Import. Die Bankanbindung (bank_sync), der Beleg-Import
|
||||
(documents) und der Telefon-Kurzbefehl (quick) bauen ihre Kandidaten selbst
|
||||
und brauchen keine Vorzeichen-Umkehr: Sie bekommen die Richtung vom
|
||||
Gegenueber mitgeliefert. Was hier ueber amount_mode entschieden wird, gilt
|
||||
deshalb ausdruecklich nicht fuer sie.
|
||||
"""
|
||||
cmap = json.loads(template.column_map or "{}")
|
||||
|
||||
def col(field: str) -> int | None:
|
||||
@@ -610,6 +646,28 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
|
||||
# Getrennte Gebührenspalte (z. B. Revolut) mindert den Betrag.
|
||||
fee = parse_amount(cell("fee"), template.decimal_sep)
|
||||
balance = parse_amount(cell("balance"), template.decimal_sep)
|
||||
|
||||
# Kreditkarten-Konvention: + ist eine Ausgabe.
|
||||
#
|
||||
# Die SALDO-Spalte muss mitgedreht werden, nicht nur der Betrag. Ein
|
||||
# Auszug, der einen Einkauf als +6 fuehrt, schreibt auch den Saldo so:
|
||||
# die Schuld steigt. Dreht man nur den Betrag, rechnet reconcile
|
||||
# „erwartet = Saldo[i] − Saldo[i−1]" gegen die alte Richtung und meldet
|
||||
# bei JEDER Zeile eine Abweichung – die einzige Kontrollinstanz des
|
||||
# Imports schluege bei einem korrekten Auszug durchgehend Alarm, und
|
||||
# der Nutzer lernte, sie zu ueberlesen.
|
||||
#
|
||||
# Vor dem Gebuehrenblock, weil der beide Werte weiterverwendet
|
||||
# (vorheriger_saldo). Sonst mischten sich zwei Vorzeichenwelten.
|
||||
#
|
||||
# Die Gebuehr bleibt, wie sie ist: Sie geht ueber abs() ein und mindert
|
||||
# den Betrag in jedem Fall.
|
||||
if template.amount_mode == AmountMode.signed_inverted:
|
||||
if amount is not None:
|
||||
amount = -amount
|
||||
if balance is not None:
|
||||
balance = -balance
|
||||
|
||||
if amount is not None and fee:
|
||||
if amount == 0:
|
||||
# Reine Gebührenzeile: Ob belastet oder erstattet („Erstattung für
|
||||
@@ -826,10 +884,28 @@ def reconcile(rows: list[dict]) -> dict:
|
||||
Duplikate und Gegenbuchungen zählen mit: deren Betrag steckt bereits in
|
||||
einer früher gebuchten Buchung. Eine Zeile, die auf ein Gegenkonto wartet,
|
||||
zählt dagegen NICHT – sie ist unterwegs verloren, und genau das soll der
|
||||
Abgleich melden."""
|
||||
Abgleich melden.
|
||||
|
||||
Die Richtung kommt aus ``type``, nicht aus dem Vorzeichen: Gebucht wird
|
||||
am Ende ``abs(betrag)`` plus Art (siehe _make_transaction). Wer eine
|
||||
Zeile von Ausgabe auf Einnahme stellt, aendert damit, was im Konto
|
||||
ankommt – und der Abgleich muss dasselbe rechnen, sonst prueft er eine
|
||||
Buchung, die so nie entsteht.
|
||||
|
||||
Fuer frisch geparste Zeilen aendert das nichts: Dort stammt ``type``
|
||||
selbst aus dem Vorzeichen, beide Wege fuehren zum selben Wert.
|
||||
"""
|
||||
if not r.get("valid"):
|
||||
return 0
|
||||
return r.get("amount_cents") or 0
|
||||
betrag = r.get("amount_cents") or 0
|
||||
art = r.get("type")
|
||||
if art == "expense":
|
||||
return -abs(betrag)
|
||||
if art == "income":
|
||||
return abs(betrag)
|
||||
# Uebertrag (oder gar kein Typ): Die Richtung steckt im Vorzeichen –
|
||||
# negativ heisst, das Geld verlaesst dieses Konto.
|
||||
return betrag
|
||||
|
||||
# Manche Banken exportieren neueste Buchung zuerst – für den Abgleich drehen.
|
||||
dated = [r["date"] for r in seq if r.get("date")]
|
||||
|
||||
Reference in New Issue
Block a user