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)
|
signed = "signed" # eine Spalte, mit Vorzeichen (− = Ausgabe)
|
||||||
debit_credit = "debit_credit" # getrennte Soll-/Haben-Spalten
|
debit_credit = "debit_credit" # getrennte Soll-/Haben-Spalten
|
||||||
abs_expense = "abs_expense" # Betrag ohne Vorzeichen, immer Ausgabe
|
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):
|
class MatchField(str, enum.Enum):
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from ..database import get_db
|
|||||||
from ..deps import get_current_user, get_owned
|
from ..deps import get_current_user, get_owned
|
||||||
from fastapi.responses import Response
|
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 (
|
from ..schemas import (
|
||||||
ImportBatchOut,
|
ImportBatchOut,
|
||||||
ImportCommitIn,
|
ImportCommitIn,
|
||||||
@@ -94,11 +95,65 @@ def _transient(user_id: int, **fields) -> ImportTemplate:
|
|||||||
async def detect_format(
|
async def detect_format(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
encoding: str = Form("utf-8"),
|
encoding: str = Form("utf-8"),
|
||||||
_: User = Depends(get_current_user),
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> 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()
|
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")
|
@router.post("/raw")
|
||||||
|
|||||||
@@ -112,8 +112,11 @@ KOPF_HINWEISE = {
|
|||||||
"date": ("buchung", "booked", "datum", "date", "valuta"),
|
"date": ("buchung", "booked", "datum", "date", "valuta"),
|
||||||
"amount": ("betrag", "amount", "umsatz", "credit", "debit"),
|
"amount": ("betrag", "amount", "umsatz", "credit", "debit"),
|
||||||
"balance": ("saldo", "balance", "kontostand"),
|
"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",
|
"payee": ("empfaenger", "empfänger", "partner", "beschreibung", "description",
|
||||||
"text", "name", "auftraggeber"),
|
"text", "name", "auftraggeber", "haendler", "händler", "merchant"),
|
||||||
"purpose": ("zweck", "reference", "verwendung", "mitteilung", "referenz"),
|
"purpose": ("zweck", "reference", "verwendung", "mitteilung", "referenz"),
|
||||||
"counterparty_iban": ("iban",),
|
"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
|
saldo_i = next((i for i in geldspalten
|
||||||
if i != betrag_i and _kopf_passt(kopf[i], "balance")), None)
|
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(
|
text_spalten = sorted(
|
||||||
((i, sum(len(w) for w in _spaltenwerte(zeilen, i)) / max(1, len(zeilen)))
|
((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],
|
key=lambda x: -x[1],
|
||||||
)
|
)
|
||||||
lange = [i for i, laenge in text_spalten if laenge >= 3]
|
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]:
|
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 "{}")
|
cmap = json.loads(template.column_map or "{}")
|
||||||
|
|
||||||
def col(field: str) -> int | None:
|
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.
|
# Getrennte Gebührenspalte (z. B. Revolut) mindert den Betrag.
|
||||||
fee = parse_amount(cell("fee"), template.decimal_sep)
|
fee = parse_amount(cell("fee"), template.decimal_sep)
|
||||||
balance = parse_amount(cell("balance"), 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 is not None and fee:
|
||||||
if amount == 0:
|
if amount == 0:
|
||||||
# Reine Gebührenzeile: Ob belastet oder erstattet („Erstattung für
|
# 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
|
Duplikate und Gegenbuchungen zählen mit: deren Betrag steckt bereits in
|
||||||
einer früher gebuchten Buchung. Eine Zeile, die auf ein Gegenkonto wartet,
|
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
|
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"):
|
if not r.get("valid"):
|
||||||
return 0
|
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.
|
# Manche Banken exportieren neueste Buchung zuerst – für den Abgleich drehen.
|
||||||
dated = [r["date"] for r in seq if r.get("date")]
|
dated = [r["date"] for r in seq if r.get("date")]
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""Kreditkarten-Auszuege: + ist eine Ausgabe.
|
||||||
|
|
||||||
|
Aus Sicht der Karte ist das richtig – ein Einkauf erhoeht die Schuld, die
|
||||||
|
Rechnungszahlung senkt sie. Certain kannte diese Leserichtung nicht.
|
||||||
|
|
||||||
|
Nebenher werden hier auch ``abs_expense`` und ``debit_credit`` geprueft: Beide
|
||||||
|
Modi gab es seit jeher, getestet war keiner von beiden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
COLMAP = json.dumps({"date": 0, "amount": 1, "payee": 2, "balance": 3})
|
||||||
|
|
||||||
|
# Startschuld 100,00. Der Auszug fuehrt Belastungen positiv – und die
|
||||||
|
# Saldo-Spalte in derselben Richtung: Die Schuld waechst.
|
||||||
|
#
|
||||||
|
# Einkauf 60,00 -> Schuld 160,00
|
||||||
|
# Tanken 24,50 -> Schuld 184,50
|
||||||
|
# Rechnung -184,50 -> Schuld 0,00
|
||||||
|
# Baeckerei 12,00 -> Schuld 12,00
|
||||||
|
KARTE_CSV = (
|
||||||
|
"Datum;Betrag;Haendler;Saldo\n"
|
||||||
|
"15.03.2026;60,00;Elektronikladen;160,00\n"
|
||||||
|
"16.03.2026;24,50;Tankstelle;184,50\n"
|
||||||
|
"20.03.2026;-184,50;Rechnungsausgleich;0,00\n"
|
||||||
|
"22.03.2026;12,00;Baeckerei;12,00\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _konto(admin, **kw):
|
||||||
|
body = {"name": "Visa Import", "type": "kreditkarte", "currency": "EUR",
|
||||||
|
"opening_balance_cents": 0}
|
||||||
|
body.update(kw)
|
||||||
|
r = admin.post("/api/accounts", json=body)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _vorschau(admin, account_id, csv_text, modus, colmap=COLMAP, **extra):
|
||||||
|
daten = {
|
||||||
|
"account_id": str(account_id), "delimiter": ";", "decimal_sep": ",",
|
||||||
|
"date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": "true",
|
||||||
|
"skip_rows": "0", "amount_mode": modus, "column_map": colmap,
|
||||||
|
}
|
||||||
|
daten.update(extra)
|
||||||
|
r = admin.post(
|
||||||
|
"/api/imports/preview",
|
||||||
|
files={"file": ("karte.csv", io.BytesIO(csv_text.encode("utf-8")), "text/csv")},
|
||||||
|
data=daten,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_inverted_dreht_die_richtung(admin):
|
||||||
|
karte = _konto(admin)
|
||||||
|
zeilen = _vorschau(admin, karte["id"], KARTE_CSV, "signed_inverted")["rows"]
|
||||||
|
|
||||||
|
assert [r["amount_cents"] for r in zeilen] == [-6000, -2450, 18450, -1200]
|
||||||
|
assert [r["type"] for r in zeilen] == ["expense", "expense", "income", "expense"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_inverted_dreht_auch_die_saldospalte(admin):
|
||||||
|
"""Der Test, der den ersten Entwurf gerettet hat.
|
||||||
|
|
||||||
|
Wer nur den Betrag dreht und die Saldo-Spalte stehen laesst, bekommt bei
|
||||||
|
JEDER Zeile eine Abweichung: Der Abgleich rechnet „erwartet = Saldo[i] −
|
||||||
|
Saldo[i−1]" und vergliche zwei entgegengesetzte Richtungen. Die einzige
|
||||||
|
Kontrollinstanz des Imports schluege bei einem korrekten Auszug
|
||||||
|
durchgehend Alarm – und waere damit wertlos.
|
||||||
|
"""
|
||||||
|
karte = _konto(admin)
|
||||||
|
rec = _vorschau(admin, karte["id"], KARTE_CSV, "signed_inverted")["reconciliation"]
|
||||||
|
|
||||||
|
assert rec["available"] is True
|
||||||
|
assert rec["ok"] is True, rec
|
||||||
|
assert rec["mismatches"] == 0
|
||||||
|
# Die Startschuld, aus der Datei zurueckgerechnet: -160,00 − (-60,00).
|
||||||
|
assert rec["opening_balance_cents"] == -10000
|
||||||
|
assert rec["final_balance_cents"] == -1200
|
||||||
|
assert rec["computed_final_cents"] == -1200
|
||||||
|
|
||||||
|
|
||||||
|
def test_halb_gedrehte_datei_faellt_auf(admin):
|
||||||
|
"""Gegenprobe zum vorigen Test – von der anderen Seite.
|
||||||
|
|
||||||
|
Eine Datei, deren Saldo-Spalte NICHT der Betragsspalte folgt, ist in sich
|
||||||
|
widerspruechlich. Genau das soll der Abgleich melden.
|
||||||
|
"""
|
||||||
|
karte = _konto(admin)
|
||||||
|
halb = (
|
||||||
|
"Datum;Betrag;Haendler;Saldo\n"
|
||||||
|
"15.03.2026;60,00;Elektronikladen;-160,00\n"
|
||||||
|
"16.03.2026;24,50;Tankstelle;-184,50\n"
|
||||||
|
)
|
||||||
|
rec = _vorschau(admin, karte["id"], halb, "signed_inverted")["reconciliation"]
|
||||||
|
assert rec["ok"] is False, rec
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_liest_denselben_auszug_verkehrt_herum(admin):
|
||||||
|
"""Was ohne den neuen Modus herauskam – und warum der Abgleich es nicht merkt.
|
||||||
|
|
||||||
|
Die Datei ist in BEIDEN Leserichtungen in sich stimmig: Betrag und Saldo
|
||||||
|
stehen in derselben Konvention. Der Abgleich prueft nur diese Stimmigkeit,
|
||||||
|
also meldet er auch hier „ok". Falsch ist trotzdem alles – die Einkaeufe
|
||||||
|
werden zu Einnahmen, und die Karte startete angeblich mit Guthaben.
|
||||||
|
|
||||||
|
Deshalb genuegt der Abgleich als Schutz nicht; der Modus muss von selbst
|
||||||
|
richtig gewaehlt werden.
|
||||||
|
"""
|
||||||
|
karte = _konto(admin)
|
||||||
|
res = _vorschau(admin, karte["id"], KARTE_CSV, "signed")
|
||||||
|
|
||||||
|
assert [r["type"] for r in res["rows"]] == ["income", "income", "expense", "income"]
|
||||||
|
assert res["reconciliation"]["ok"] is True
|
||||||
|
assert res["reconciliation"]["opening_balance_cents"] == 10000
|
||||||
|
|
||||||
|
|
||||||
|
def test_abs_expense_taugt_nicht_fuer_karten(admin):
|
||||||
|
"""Der scheinbare Ausweg, den es schon gab.
|
||||||
|
|
||||||
|
``abs_expense`` macht jede Zeile zur Ausgabe – auch die Rechnungszahlung.
|
||||||
|
Die Schuld waechst dann immer weiter, und der Abgleich schlaegt an.
|
||||||
|
"""
|
||||||
|
karte = _konto(admin)
|
||||||
|
res = _vorschau(admin, karte["id"], KARTE_CSV, "abs_expense")
|
||||||
|
|
||||||
|
assert [r["amount_cents"] for r in res["rows"]] == [-6000, -2450, -18450, -1200]
|
||||||
|
assert all(r["type"] == "expense" for r in res["rows"])
|
||||||
|
assert res["reconciliation"]["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_debit_credit_trennt_soll_und_haben(admin):
|
||||||
|
"""Getrennte Spalten – bisher ungetestet.
|
||||||
|
|
||||||
|
Hier benennen die Spalten die Richtung selbst; eine Umkehr gibt es nicht
|
||||||
|
und braucht es nicht.
|
||||||
|
"""
|
||||||
|
karte = _konto(admin, name="Soll Haben")
|
||||||
|
csv = (
|
||||||
|
"Datum;Soll;Haben;Text\n"
|
||||||
|
"15.03.2026;50,00;;Einkauf\n"
|
||||||
|
"16.03.2026;;120,00;Gutschrift\n"
|
||||||
|
"17.03.2026;-30,00;;Vorzeichen egal\n"
|
||||||
|
)
|
||||||
|
colmap = json.dumps({"date": 0, "debit": 1, "credit": 2, "payee": 3})
|
||||||
|
zeilen = _vorschau(admin, karte["id"], csv, "debit_credit", colmap)["rows"]
|
||||||
|
|
||||||
|
assert [r["amount_cents"] for r in zeilen] == [-5000, 12000, -3000]
|
||||||
|
assert [r["type"] for r in zeilen] == ["expense", "income", "expense"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gebuehr_wird_auch_bei_umgekehrtem_vorzeichen_abgezogen(admin):
|
||||||
|
"""Eine Gebuehr mindert den Betrag – in jeder Leserichtung.
|
||||||
|
|
||||||
|
Sie geht ueber abs() ein und bleibt deshalb von der Umkehr unberuehrt.
|
||||||
|
Wuerde sie mitgedreht, zoege sie die Ausgabe faelschlich zusammen statt
|
||||||
|
sie zu erhoehen.
|
||||||
|
"""
|
||||||
|
karte = _konto(admin, name="Mit Gebuehr")
|
||||||
|
csv = (
|
||||||
|
"Datum;Betrag;Haendler;Gebuehr;Saldo\n"
|
||||||
|
"15.03.2026;60,00;Laden im Ausland;2,00;162,00\n"
|
||||||
|
"16.03.2026;10,00;Zweiter Laden;0,00;172,00\n"
|
||||||
|
)
|
||||||
|
colmap = json.dumps({"date": 0, "amount": 1, "payee": 2, "fee": 3, "balance": 4})
|
||||||
|
res = _vorschau(admin, karte["id"], csv, "signed_inverted", colmap)
|
||||||
|
|
||||||
|
assert [r["amount_cents"] for r in res["rows"]] == [-6200, -1000]
|
||||||
|
assert res["reconciliation"]["ok"] is True, res["reconciliation"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gebuchte_karte_steht_im_minus_und_die_rechnung_holt_sie_zurueck(admin):
|
||||||
|
"""Vom Auszug bis zum Kontostand – der Weg, um den es geht."""
|
||||||
|
karte = _konto(admin, name="Visa Gebucht", credit_limit_cents=250000)
|
||||||
|
zeilen = _vorschau(admin, karte["id"], KARTE_CSV, "signed_inverted")["rows"]
|
||||||
|
|
||||||
|
r = admin.post("/api/imports/commit", json={
|
||||||
|
"account_id": karte["id"], "filename": "karte.csv", "rows": zeilen,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# 3 Ausgaben (−97,50) und die Rechnungszahlung (+184,50) = +87,00 … aber
|
||||||
|
# der Auszug beginnt mit einer Schuld, die Certain nicht kennt. Gebucht
|
||||||
|
# wird nur, was in der Datei steht.
|
||||||
|
saldo = admin.get(f"/api/accounts/{karte['id']}").json()["balance_cents"]
|
||||||
|
assert saldo == 8800
|
||||||
|
|
||||||
|
# Und nach dem Nachtragen der Startschuld stimmt der Stand des Auszugs.
|
||||||
|
admin.patch(f"/api/accounts/{karte['id']}", json={"opening_balance_cents": -10000})
|
||||||
|
saldo = admin.get(f"/api/accounts/{karte['id']}").json()["balance_cents"]
|
||||||
|
assert saldo == -1200, "der Schlussstand der Datei"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Der Abgleich folgt der Buchungsart ------------------------------------
|
||||||
|
#
|
||||||
|
# Direkt gegen die Funktion, nicht ueber HTTP: Die Vorschau rechnet den
|
||||||
|
# Abgleich einmal serverseitig und liefert ihn mit. Eine von Hand geaenderte
|
||||||
|
# Richtung erreicht ihn auf diesem Weg gar nicht – die Lage laesst sich ueber
|
||||||
|
# die Schnittstelle also nicht herstellen.
|
||||||
|
|
||||||
|
def test_abgleich_folgt_der_buchungsart():
|
||||||
|
"""Wer eine Zeile umstellt, aendert, was im Konto ankommt.
|
||||||
|
|
||||||
|
Gebucht wird abs(Betrag) plus Art (_make_transaction). Rechnete der
|
||||||
|
Abgleich weiter mit dem Vorzeichen, prueft er eine Buchung, die so nie
|
||||||
|
entsteht.
|
||||||
|
"""
|
||||||
|
from app.services.csv_import import reconcile
|
||||||
|
|
||||||
|
zeilen = [
|
||||||
|
{"valid": True, "type": "expense", "amount_cents": -5000, "balance_cents": 5000},
|
||||||
|
# Dieselbe Zeile, vom Nutzer auf Einnahme gestellt: Das Vorzeichen des
|
||||||
|
# Betrags blieb negativ, gebucht werden aber +30,00.
|
||||||
|
{"valid": True, "type": "income", "amount_cents": -3000, "balance_cents": 8000},
|
||||||
|
]
|
||||||
|
rec = reconcile(zeilen)
|
||||||
|
assert rec["ok"] is True, rec
|
||||||
|
assert rec["opening_balance_cents"] == 10000 # 5000 − (−5000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_abgleich_bleibt_bei_ungetypten_zeilen_beim_vorzeichen():
|
||||||
|
"""Ohne Art – und bei Uebertraegen – zaehlt weiter das Vorzeichen."""
|
||||||
|
from app.services.csv_import import reconcile
|
||||||
|
|
||||||
|
zeilen = [
|
||||||
|
{"valid": True, "amount_cents": -5000, "balance_cents": 5000},
|
||||||
|
{"valid": True, "type": "transfer", "amount_cents": -3000, "balance_cents": 2000},
|
||||||
|
]
|
||||||
|
assert reconcile(zeilen)["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Erkennung: das Konto und die Leserichtung ------------------------------
|
||||||
|
|
||||||
|
def _erkennen(admin, csv_text):
|
||||||
|
r = admin.post(
|
||||||
|
"/api/imports/detect",
|
||||||
|
files={"file": ("karte.csv", io.BytesIO(csv_text.encode("utf-8")), "text/csv")},
|
||||||
|
data={"encoding": "utf-8"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# Mit eigener IBAN-Spalte, damit erkenne_konto das Konto sicher findet.
|
||||||
|
MIT_IBAN = (
|
||||||
|
"Konto;Datum;Betrag;Haendler;Saldo\n"
|
||||||
|
"CH9300762011623852957;15.03.2026;60,00;Elektronikladen;160,00\n"
|
||||||
|
"CH9300762011623852957;16.03.2026;24,50;Tankstelle;184,50\n"
|
||||||
|
"CH9300762011623852957;20.03.2026;-184,50;Rechnungsausgleich;0,00\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_erkennung_schlaegt_bei_kreditkarte_die_umkehr_vor(admin):
|
||||||
|
karte = _konto(admin, name="Visa Erkennung",
|
||||||
|
identifiers=[{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}])
|
||||||
|
erkannt = _erkennen(admin, MIT_IBAN)
|
||||||
|
|
||||||
|
assert erkannt["ok"] is True, erkannt
|
||||||
|
assert erkannt["konto"]["account_id"] == karte["id"]
|
||||||
|
assert erkannt["config"]["amount_mode"] == "signed_inverted"
|
||||||
|
assert erkannt.get("kreditkarte") is True
|
||||||
|
# Und es steht dabei, warum – eine stille Umkehr waere schlimmer als keine.
|
||||||
|
assert "Kreditkarte" in erkannt["begruendung"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_erkennung_laesst_ein_girokonto_in_ruhe(admin):
|
||||||
|
"""Die Gegenprobe. Dieselbe Datei, anderer Kontotyp."""
|
||||||
|
giro = admin.post("/api/accounts", json={
|
||||||
|
"name": "Giro Erkennung", "type": "girokonto", "currency": "EUR",
|
||||||
|
"identifiers": [{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}],
|
||||||
|
}).json()
|
||||||
|
erkannt = _erkennen(admin, MIT_IBAN)
|
||||||
|
|
||||||
|
assert erkannt["konto"]["account_id"] == giro["id"]
|
||||||
|
assert erkannt["config"]["amount_mode"] == "signed"
|
||||||
|
assert "kreditkarte" not in erkannt
|
||||||
|
|
||||||
|
|
||||||
|
def test_erkennung_raet_kein_konto(admin):
|
||||||
|
"""Ohne Anhaltspunkt bleibt die Wahl beim Nutzer – und die Umkehr aus.
|
||||||
|
|
||||||
|
Ein Auszug im falschen Konto verfaelscht zwei Salden auf einmal. Lieber
|
||||||
|
einmal fragen als einmal raten.
|
||||||
|
"""
|
||||||
|
_konto(admin, name="Visa Ohne Merkmal")
|
||||||
|
erkannt = _erkennen(admin, KARTE_CSV)
|
||||||
|
|
||||||
|
assert erkannt["konto"]["account_id"] is None
|
||||||
|
assert erkannt["config"]["amount_mode"] == "signed"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Spaltenerkennung bei Kartenauszuegen ----------------------------------
|
||||||
|
|
||||||
|
def test_haendlerspalte_wird_als_empfaenger_erkannt(admin):
|
||||||
|
"""„Haendler" heisst die Spalte auf Kartenauszuegen – „Empfaenger" nie.
|
||||||
|
|
||||||
|
Und die eigene Kontonummer, die viele Auszuege in jeder Zeile mitfuehren,
|
||||||
|
darf den Empfaenger nicht gewinnen: Sie ist laenger als jeder
|
||||||
|
Haendlername, und die Wahl lief bisher ueber die Textlaenge. Heraus kaeme
|
||||||
|
eine Liste, in der bei jeder Buchung dasselbe steht.
|
||||||
|
"""
|
||||||
|
erkannt = _erkennen(admin, MIT_IBAN)
|
||||||
|
spalten = erkannt["config"]["column_map"]
|
||||||
|
|
||||||
|
assert spalten["payee"] == 3, erkannt["begruendung"] # „Haendler"
|
||||||
|
assert spalten["amount"] == 2
|
||||||
|
assert spalten["balance"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_zweckspalte_bleibt_erhalten(admin):
|
||||||
|
"""Gegenprobe: Lange Verwendungszwecke sind keine Kontonummern.
|
||||||
|
|
||||||
|
Ohne die Bedingung „in fast jeder Zeile steht dasselbe" haette die
|
||||||
|
Aussortierung auch sie getroffen.
|
||||||
|
"""
|
||||||
|
csv_text = (
|
||||||
|
"Datum;Betrag;Empfaenger;Verwendungszweck\n"
|
||||||
|
"15.03.2026;-50,00;REWE;Rechnung 4711 vom 01.02.2026\n"
|
||||||
|
"16.03.2026;-20,00;Migros;Bestellung 998877 Lieferung Nord\n"
|
||||||
|
"17.03.2026;-30,00;Coop;Abschlagszahlung 20260317 Vertrag 55\n"
|
||||||
|
)
|
||||||
|
spalten = _erkennen(admin, csv_text)["config"]["column_map"]
|
||||||
|
assert spalten["payee"] == 2
|
||||||
|
assert spalten["purpose"] == 3
|
||||||
@@ -436,6 +436,7 @@
|
|||||||
"skipRows": "Zeilen überspringen",
|
"skipRows": "Zeilen überspringen",
|
||||||
"hasHeader": "Erste Zeile ist Kopfzeile",
|
"hasHeader": "Erste Zeile ist Kopfzeile",
|
||||||
"modeSigned": "Ein Betrag (mit Vorzeichen)",
|
"modeSigned": "Ein Betrag (mit Vorzeichen)",
|
||||||
|
"modeSignedInverted": "Eine Spalte, + ist eine Ausgabe (Kreditkarte)",
|
||||||
"modeDebitCredit": "Getrennte Soll-/Haben-Spalten",
|
"modeDebitCredit": "Getrennte Soll-/Haben-Spalten",
|
||||||
"modeAbsExpense": "Ein Betrag (immer Ausgabe)",
|
"modeAbsExpense": "Ein Betrag (immer Ausgabe)",
|
||||||
"readFile": "Datei einlesen",
|
"readFile": "Datei einlesen",
|
||||||
@@ -453,6 +454,8 @@
|
|||||||
"rawHint": "Nur die ersten Zeilen – sie dienen nur der Spaltenzuordnung.",
|
"rawHint": "Nur die ersten Zeilen – sie dienen nur der Spaltenzuordnung.",
|
||||||
"makePreviewNote": "„Vorschau erstellen“ zeigt danach ALLE Buchungen zum Prüfen und Buchen.",
|
"makePreviewNote": "„Vorschau erstellen“ zeigt danach ALLE Buchungen zum Prüfen und Buchen.",
|
||||||
"summary": "{{total}} Zeilen erkannt · {{n}} werden gebucht (Duplikate/ungültige ausgenommen).",
|
"summary": "{{total}} Zeilen erkannt · {{n}} werden gebucht (Duplikate/ungültige ausgenommen).",
|
||||||
|
"setTypeForAll": "Alle gewählten Zeilen:",
|
||||||
|
"flipAll": "Richtung umdrehen",
|
||||||
"noneValid": "Keine gültige Buchung erkannt. Prüfe Datumsformat, Dezimaltrenner und Spaltenzuordnung – am einfachsten oben die passende Bank-Vorlage wählen.",
|
"noneValid": "Keine gültige Buchung erkannt. Prüfe Datumsformat, Dezimaltrenner und Spaltenzuordnung – am einfachsten oben die passende Bank-Vorlage wählen.",
|
||||||
"commit": "{{n}} Buchungen übernehmen",
|
"commit": "{{n}} Buchungen übernehmen",
|
||||||
"imported": "{{n}} Buchungen importiert.",
|
"imported": "{{n}} Buchungen importiert.",
|
||||||
@@ -489,6 +492,8 @@
|
|||||||
"removeEntry": "Eintrag entfernen",
|
"removeEntry": "Eintrag entfernen",
|
||||||
"removeEntryConfirm": "Den Verlaufs-Eintrag „{{file}}“ entfernen? Die Buchungen sind bereits zurückgenommen.",
|
"removeEntryConfirm": "Den Verlaufs-Eintrag „{{file}}“ entfernen? Die Buchungen sind bereits zurückgenommen.",
|
||||||
"reconMixedHint": "Enthält die Datei mehrere Konten oder Produkte? Revolut legt Giro, Erspartes und Anlagekonto in EINE Datei, jedes mit eigener Kontostand-Spalte. Dann greift der Abgleich erst, wenn du oben den Zeilenfilter setzt (z. B. Produkt = Giro) und die übrigen Produkte einzeln einliest.",
|
"reconMixedHint": "Enthält die Datei mehrere Konten oder Produkte? Revolut legt Giro, Erspartes und Anlagekonto in EINE Datei, jedes mit eigener Kontostand-Spalte. Dann greift der Abgleich erst, wenn du oben den Zeilenfilter setzt (z. B. Produkt = Giro) und die übrigen Produkte einzeln einliest.",
|
||||||
|
"reconCardHint": "Bei Kreditkarten steht + oft für eine Ausgabe. Stimmt die Leserichtung? Unter „Format“ lässt sie sich umstellen.",
|
||||||
|
"cardSignNote": "Kreditkarte: + im Auszug wird als Ausgabe gelesen, − als Zahlung der Rechnung. Umschaltbar unter „Format“.",
|
||||||
"filterTitle": "Diese Datei enthält mehrere Konten",
|
"filterTitle": "Diese Datei enthält mehrere Konten",
|
||||||
"filterValueOption": "{{value}} ({{count}} Zeilen)",
|
"filterValueOption": "{{value}} ({{count}} Zeilen)",
|
||||||
"removeAllUndone": "Rückgängig gemachte entfernen",
|
"removeAllUndone": "Rückgängig gemachte entfernen",
|
||||||
|
|||||||
@@ -436,6 +436,7 @@
|
|||||||
"skipRows": "Skip rows",
|
"skipRows": "Skip rows",
|
||||||
"hasHeader": "First row is a header",
|
"hasHeader": "First row is a header",
|
||||||
"modeSigned": "Single amount (signed)",
|
"modeSigned": "Single amount (signed)",
|
||||||
|
"modeSignedInverted": "Single column, + is an expense (credit card)",
|
||||||
"modeDebitCredit": "Separate debit/credit columns",
|
"modeDebitCredit": "Separate debit/credit columns",
|
||||||
"modeAbsExpense": "Single amount (always expense)",
|
"modeAbsExpense": "Single amount (always expense)",
|
||||||
"readFile": "Read file",
|
"readFile": "Read file",
|
||||||
@@ -453,6 +454,8 @@
|
|||||||
"rawHint": "Only the first rows – just for mapping the columns.",
|
"rawHint": "Only the first rows – just for mapping the columns.",
|
||||||
"makePreviewNote": "“Build preview” then shows ALL transactions to review and import.",
|
"makePreviewNote": "“Build preview” then shows ALL transactions to review and import.",
|
||||||
"summary": "{{total}} rows detected · {{n}} will be imported (duplicates/invalid excluded).",
|
"summary": "{{total}} rows detected · {{n}} will be imported (duplicates/invalid excluded).",
|
||||||
|
"setTypeForAll": "All selected rows:",
|
||||||
|
"flipAll": "Flip direction",
|
||||||
"noneValid": "No valid transaction detected. Check date format, decimal separator and column mapping – easiest is to pick the matching bank preset above.",
|
"noneValid": "No valid transaction detected. Check date format, decimal separator and column mapping – easiest is to pick the matching bank preset above.",
|
||||||
"commit": "Import {{n}} transactions",
|
"commit": "Import {{n}} transactions",
|
||||||
"imported": "Imported {{n}} transactions.",
|
"imported": "Imported {{n}} transactions.",
|
||||||
@@ -489,6 +492,8 @@
|
|||||||
"removeEntry": "Remove entry",
|
"removeEntry": "Remove entry",
|
||||||
"removeEntryConfirm": "Remove the history entry “{{file}}”? Its transactions have already been undone.",
|
"removeEntryConfirm": "Remove the history entry “{{file}}”? Its transactions have already been undone.",
|
||||||
"reconMixedHint": "Does the file hold several accounts or products? Revolut puts current, savings and investment into ONE file, each with its own balance column. The check only works once you set the row filter above (e.g. Product = Current) and import the other products separately.",
|
"reconMixedHint": "Does the file hold several accounts or products? Revolut puts current, savings and investment into ONE file, each with its own balance column. The check only works once you set the row filter above (e.g. Product = Current) and import the other products separately.",
|
||||||
|
"reconCardHint": "On credit cards, + often means an expense. Is the reading direction right? You can switch it under “Format”.",
|
||||||
|
"cardSignNote": "Credit card: + in the statement is read as an expense, − as a payment of the bill. Switchable under “Format”.",
|
||||||
"filterTitle": "This file holds several accounts",
|
"filterTitle": "This file holds several accounts",
|
||||||
"filterValueOption": "{{value}} ({{count}} rows)",
|
"filterValueOption": "{{value}} ({{count}} rows)",
|
||||||
"removeAllUndone": "Remove undone entries",
|
"removeAllUndone": "Remove undone entries",
|
||||||
|
|||||||
+120
-9
@@ -25,6 +25,18 @@ const DATE_FORMATS = [
|
|||||||
{ v: "%d.%m.%Y %H:%M:%S", l: "TT.MM.JJJJ SS:MM:SS" },
|
{ v: "%d.%m.%Y %H:%M:%S", l: "TT.MM.JJJJ SS:MM:SS" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Verlaesst das Geld dieses Konto (-1) oder kommt es an (+1)?
|
||||||
|
//
|
||||||
|
// Bei Ausgabe/Einnahme entscheidet die ART, nicht das Vorzeichen des
|
||||||
|
// Rohbetrags: Gebucht wird am Ende abs(Betrag) plus Art (_make_transaction),
|
||||||
|
// und der Saldo-Abgleich rechnet ebenso. Bei einem Uebertrag steht die
|
||||||
|
// Richtung weiter im Vorzeichen – negativ heisst, es geht hier weg.
|
||||||
|
function richtung(r) {
|
||||||
|
if (r.type === "expense") return -1;
|
||||||
|
if (r.type === "income") return 1;
|
||||||
|
return (r.amount_cents || 0) < 0 ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
function defaultConfig() {
|
function defaultConfig() {
|
||||||
return {
|
return {
|
||||||
delimiter: ";",
|
delimiter: ";",
|
||||||
@@ -253,13 +265,25 @@ export default function Import() {
|
|||||||
// Format aus der Datei ableiten, damit niemand Spalten von Hand zuordnet.
|
// Format aus der Datei ableiten, damit niemand Spalten von Hand zuordnet.
|
||||||
// Das war die haeufigste Fehlerquelle: falsches Datumsformat = keine Zeile
|
// Das war die haeufigste Fehlerquelle: falsches Datumsformat = keine Zeile
|
||||||
// gueltig, falscher Dezimaltrenner = Betraege um Faktor 100 daneben.
|
// gueltig, falscher Dezimaltrenner = Betraege um Faktor 100 daneben.
|
||||||
async function formatErkennen(f) {
|
//
|
||||||
|
// `kontoUebernehmen` ist nur fuer Dateien vom Telefon aus: Dort hat der
|
||||||
|
// Nutzer das Konto im Kurzbefehl schon ausdruecklich gewaehlt, und eine
|
||||||
|
// ausdrueckliche Wahl schlaegt eine erkannte.
|
||||||
|
async function formatErkennen(f, kontoUebernehmen = true) {
|
||||||
setErkannt(null);
|
setErkannt(null);
|
||||||
try {
|
try {
|
||||||
const daten = new FormData();
|
const daten = new FormData();
|
||||||
daten.append("file", f);
|
daten.append("file", f);
|
||||||
const r = await api.detectFormat(daten);
|
const r = await api.detectFormat(daten);
|
||||||
setErkannt(r);
|
setErkannt(r);
|
||||||
|
// Das Konto ist die gefaehrlichste Entscheidung des Imports – ein Auszug
|
||||||
|
// im falschen Konto verfaelscht zwei Salden. Die Liste steht hier
|
||||||
|
// ausserdem auf dem ERSTEN Konto vorbelegt; ein erkanntes Konto muss
|
||||||
|
// diese Vorbelegung also ueberschreiben duerfen. Erkannt wird nur, wenn
|
||||||
|
// es eindeutig ist (erkenne_konto raet nicht).
|
||||||
|
if (kontoUebernehmen && r.konto?.account_id) {
|
||||||
|
setAccountId(String(r.konto.account_id));
|
||||||
|
}
|
||||||
if (r.ok && r.config) {
|
if (r.ok && r.config) {
|
||||||
const c = r.config;
|
const c = r.config;
|
||||||
setCfg({
|
setCfg({
|
||||||
@@ -286,7 +310,7 @@ export default function Import() {
|
|||||||
setRows(null);
|
setRows(null);
|
||||||
setMode("csv");
|
setMode("csv");
|
||||||
const datei = new File([blob], u.filename, { type: "text/csv" });
|
const datei = new File([blob], u.filename, { type: "text/csv" });
|
||||||
formatErkennen(datei);
|
formatErkennen(datei, false);
|
||||||
toast(t("import.phoneFileLoaded", { name: u.filename }));
|
toast(t("import.phoneFileLoaded", { name: u.filename }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, "error");
|
toast(e.message, "error");
|
||||||
@@ -325,8 +349,42 @@ export default function Import() {
|
|||||||
|
|
||||||
const includeCount = rows ? rows.filter((r) => r.include).length : 0;
|
const includeCount = rows ? rows.filter((r) => r.include).length : 0;
|
||||||
|
|
||||||
|
// Summe je Richtung ueber die Zeilen, die wirklich gebucht werden.
|
||||||
|
//
|
||||||
|
// Eine gespiegelte Datei sieht man daran auf einen Blick: Ein Kartenauszug
|
||||||
|
// mit 3.000 Einnahmen und 200 Ausgaben ist verkehrt herum gelesen. Zeile
|
||||||
|
// fuer Zeile faellt das bei 150 Positionen niemandem auf.
|
||||||
|
const summen = useMemo(() => {
|
||||||
|
const s = { aus: 0, ein: 0 };
|
||||||
|
for (const r of rows || []) {
|
||||||
|
if (!r.include || !r.valid) continue;
|
||||||
|
const betrag = Math.abs(r.amount_cents || 0);
|
||||||
|
if (richtung(r) < 0) s.aus += betrag;
|
||||||
|
else s.ein += betrag;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
// Aktualisiert eine Zeile über ihren stabilen Erkennungs-Index (die Anzeige in
|
// Aktualisiert eine Zeile über ihren stabilen Erkennungs-Index (die Anzeige in
|
||||||
// der Tabelle kann sortiert/gefiltert sein, der Index bleibt gleich).
|
// der Tabelle kann sortiert/gefiltert sein, der Index bleibt gleich).
|
||||||
|
// Nur was gebucht wird: Ungueltige und abgewaehlte Zeilen bleiben, wie sie
|
||||||
|
// sind – sie landen ohnehin nirgends.
|
||||||
|
function typFuerAlleSetzen(typ) {
|
||||||
|
setRows((rs) => rs.map((r) => (r.include && r.valid ? { ...r, type: typ } : r)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der haeufigere Fall als „alles Ausgabe": Die Datei ist stimmig, nur
|
||||||
|
// andersherum gelesen. Uebertraege bleiben unberuehrt – deren Richtung
|
||||||
|
// steckt im Vorzeichen, nicht in der Art.
|
||||||
|
function richtungUmdrehen() {
|
||||||
|
setRows((rs) => rs.map((r) => {
|
||||||
|
if (!r.include || !r.valid) return r;
|
||||||
|
if (r.type === "expense") return { ...r, type: "income" };
|
||||||
|
if (r.type === "income") return { ...r, type: "expense" };
|
||||||
|
return r;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function updateRow(rowIndex, patch) {
|
function updateRow(rowIndex, patch) {
|
||||||
setRows((rs) => rs.map((r) => (r.index === rowIndex ? { ...r, ...patch } : r)));
|
setRows((rs) => rs.map((r) => (r.index === rowIndex ? { ...r, ...patch } : r)));
|
||||||
}
|
}
|
||||||
@@ -349,13 +407,23 @@ export default function Import() {
|
|||||||
: <span className="amount neg">{t("import.badDate")}</span>),
|
: <span className="amount neg">{t("import.badDate")}</span>),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Richtung aus `type`, nicht aus dem Vorzeichen des Rohbetrags.
|
||||||
|
//
|
||||||
|
// Gebucht wird am Ende abs(Betrag) plus Art (_make_transaction). Wer
|
||||||
|
// eine Zeile von Ausgabe auf Einnahme stellte, sah hier bisher KEINE
|
||||||
|
// Aenderung – die Liste zeigte etwas anderes als das, was gebucht wird.
|
||||||
|
// Derselbe Fehler steckte einmal in Money.jsx.
|
||||||
key: "amount", header: t("transactions.amount"), align: "num", width: 120,
|
key: "amount", header: t("transactions.amount"), align: "num", width: 120,
|
||||||
sortValue: (r) => r.amount_cents || 0,
|
sortValue: (r) => richtung(r) * Math.abs(r.amount_cents || 0),
|
||||||
render: (r) => (
|
render: (r) => {
|
||||||
<span className={`amount ${(r.amount_cents || 0) < 0 ? "neg" : "pos"}`}>
|
const raus = richtung(r) < 0;
|
||||||
|
return (
|
||||||
|
<span className={`amount ${raus ? "neg" : "pos"}`}>
|
||||||
|
{raus ? "−" : "+"}
|
||||||
{formatMoney(Math.abs(r.amount_cents || 0), acc?.currency || "EUR")}
|
{formatMoney(Math.abs(r.amount_cents || 0), acc?.currency || "EUR")}
|
||||||
</span>
|
</span>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "type", header: t("import.typeCol"), width: 128,
|
key: "type", header: t("import.typeCol"), width: 128,
|
||||||
@@ -581,6 +649,7 @@ export default function Import() {
|
|||||||
<span>{t("import.amountMode")}</span>
|
<span>{t("import.amountMode")}</span>
|
||||||
<select value={config.amount_mode} onChange={(e) => setCfg({ amount_mode: e.target.value })}>
|
<select value={config.amount_mode} onChange={(e) => setCfg({ amount_mode: e.target.value })}>
|
||||||
<option value="signed">{t("import.modeSigned")}</option>
|
<option value="signed">{t("import.modeSigned")}</option>
|
||||||
|
<option value="signed_inverted">{t("import.modeSignedInverted")}</option>
|
||||||
<option value="debit_credit">{t("import.modeDebitCredit")}</option>
|
<option value="debit_credit">{t("import.modeDebitCredit")}</option>
|
||||||
<option value="abs_expense">{t("import.modeAbsExpense")}</option>
|
<option value="abs_expense">{t("import.modeAbsExpense")}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -759,7 +828,27 @@ export default function Import() {
|
|||||||
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
|
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted small">{t("import.summary", { total: rows.length, n: includeCount })}</p>
|
<p className="muted small">
|
||||||
|
{t("import.summary", { total: rows.length, n: includeCount })}
|
||||||
|
{" · "}
|
||||||
|
<span className="amount neg">
|
||||||
|
−{formatMoney(summen.aus, acc?.currency || "EUR")}
|
||||||
|
</span>
|
||||||
|
{" / "}
|
||||||
|
<span className="amount pos">
|
||||||
|
+{formatMoney(summen.ein, acc?.currency || "EUR")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/* Eine stille Vorzeichen-Umkehr waere schlimmer als gar keine: Wer
|
||||||
|
nicht weiss, dass umgekehrt gelesen wird, haelt die Liste fuer
|
||||||
|
falsch statt fuer anders herum. Deshalb steht es hier – und dabei,
|
||||||
|
wo man es aendert. */}
|
||||||
|
{config.amount_mode === "signed_inverted" && (
|
||||||
|
<div className="alert" style={{ marginBottom: "var(--sp-3)" }}>
|
||||||
|
<Icon name="credit-card" size={16} />
|
||||||
|
<span>{t("import.cardSignNote")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{rows.length > 0 && includeCount === 0 && (
|
{rows.length > 0 && includeCount === 0 && (
|
||||||
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
|
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
|
||||||
<Icon name="alert" size={16} />
|
<Icon name="alert" size={16} />
|
||||||
@@ -791,8 +880,13 @@ export default function Import() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Häufigste Ursache: mehrere Konten in einer Datei, kein Zeilenfilter. */}
|
{/* Häufigste Ursache: mehrere Konten in einer Datei, kein
|
||||||
{config.filter_column == null && (
|
Zeilenfilter. Bei einer Kreditkarte aber nicht: Dort ist
|
||||||
|
die Leserichtung die naheliegende Erklaerung, und der
|
||||||
|
Revolut-Hinweis fuehrte nur von der Ursache weg. */}
|
||||||
|
{acc?.type === "kreditkarte" ? (
|
||||||
|
<div className="muted small">{t("import.reconCardHint")}</div>
|
||||||
|
) : config.filter_column == null && (
|
||||||
<div className="muted small">{t("import.reconMixedHint")}</div>
|
<div className="muted small">{t("import.reconMixedHint")}</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -805,6 +899,23 @@ export default function Import() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Ist eine Datei durchgehend verkehrt herum gelesen, sind ALLE
|
||||||
|
Zeilen betroffen. Einzeln umzustellen ist bei 150 Positionen
|
||||||
|
keine Moeglichkeit, sondern eine Zumutung. */}
|
||||||
|
<div className="field-inline" style={{ marginBottom: "var(--sp-2)" }}>
|
||||||
|
<span className="muted small">{t("import.setTypeForAll")}</span>
|
||||||
|
<button type="button" className="btn sm"
|
||||||
|
onClick={() => typFuerAlleSetzen("expense")}>
|
||||||
|
{t("transactions.expense")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn sm"
|
||||||
|
onClick={() => typFuerAlleSetzen("income")}>
|
||||||
|
{t("transactions.income")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn sm" onClick={richtungUmdrehen}>
|
||||||
|
{t("import.flipAll")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
id="import-review"
|
id="import-review"
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
|||||||
Reference in New Issue
Block a user