diff --git a/backend/app/routers/quick.py b/backend/app/routers/quick.py index 1ef69f5..bbd95a0 100644 --- a/backend/app/routers/quick.py +++ b/backend/app/routers/quick.py @@ -154,7 +154,7 @@ MAX_UPLOAD = 5 * 1024 * 1024 @router.post("/statement", status_code=status.HTTP_201_CREATED) async def quick_statement( file: UploadFile = File(...), - account_id: int = Form(...), + account_id: int | None = Form(None), marke: ApiToken = Depends(require_quick_token), db: Session = Depends(get_db), ) -> dict: @@ -165,7 +165,6 @@ async def quick_statement( wartet in der Import-Ansicht – dort gibt es Vorschau, Saldo-Abgleich und die Möglichkeit, einzelne Zeilen abzuwählen. """ - konto = _konto(db, marke, account_id) daten = await file.read() if not daten: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Datei ist leer") @@ -176,6 +175,33 @@ async def quick_statement( f"{MAX_UPLOAD // 1024 // 1024} MB)", ) + # Ohne ausdrückliche Angabe das Konto aus der Datei ableiten – dann reicht + # EIN Kurzbefehl fürs Telefon statt einer je Konto. + if account_id is not None: + konto = _konto(db, marke, account_id) + herkunft = "angegeben" + else: + user = db.get(User, marke.owner_id) + erkannt = csv_import.erkenne_format(daten) + gefunden = csv_import.erkenne_konto(db, user, daten, erkannt) + if gefunden["account_id"] is None: + if marke.default_account_id: + konto = _konto(db, marke, marke.default_account_id) + herkunft = "Standardkonto des Schlüssels" + else: + namen = ", ".join( + f"{a.name} = {a.id}" for a in db.query(Account).filter( + Account.owner_id == marke.owner_id, Account.is_archived.is_(False)) + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=(f"{gefunden['grund']}. Bitte account_id im Kurzbefehl " + f"angeben. Deine Konten: {namen}"), + ) + else: + konto = _konto(db, marke, gefunden["account_id"]) + herkunft = gefunden["grund"] + upload = PendingUpload( owner_id=marke.owner_id, account_id=konto.id, @@ -190,5 +216,6 @@ async def quick_statement( "id": upload.id, "konto": konto.name, "size_bytes": upload.size_bytes, - "message": f"Empfangen für {konto.name} – zum Prüfen in Certain öffnen", + "erkannt_als": herkunft, + "message": f"Empfangen für {konto.name} ({herkunft}) – zum Prüfen in Certain öffnen", } diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index aaa0f9a..725b326 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import Session from ..models import ( Account, + AccountIdentifier, InboxItem, InboxStatus, AmountMode, @@ -273,6 +274,82 @@ def erkenne_format(data: bytes, encoding: str = "utf-8") -> dict: } +def erkenne_konto(db: Session, user: User, data: bytes, erkannt: dict) -> dict: + """Zu welchem Konto gehört diese Datei? + + Damit reicht EIN Kurzbefehl fürs Telefon statt einer je Konto. Zwei + Anhaltspunkte, in dieser Reihenfolge: + + 1. Eine Spalte mit der EIGENEN IBAN (Raiffeisen führt sie in Spalte 0). + Trifft sie einen Erkennungs-Text eines Kontos, ist die Sache eindeutig. + 2. Die Währung der Zeilen zusammen mit der erkannten Bank. Revolut liefert + je Währung eine eigene Datei; welches Konto gemeint ist, verrät der + Import-Verlauf – dorthin ging zuletzt eine Datei derselben Bank. + + Bleibt es mehrdeutig, wird NICHT geraten: Ein Auszug im falschen Konto + verfälscht zwei Salden auf einmal. + """ + if not erkannt.get("ok"): + return {"account_id": None, "grund": erkannt.get("grund", "Format unbekannt")} + + config = erkannt["config"] + text = decode_bytes(data, config.get("encoding") or "utf-8") + zeilen = [r for r in csv.reader(io.StringIO(text), delimiter=config["delimiter"]) + if any((c or "").strip() for c in r)] + if len(zeilen) < 2: + return {"account_id": None, "grund": "Zu wenige Zeilen"} + proben = zeilen[1:31] + + konten = db.query(Account).filter(Account.owner_id == user.id, + Account.is_archived.is_(False)).all() + + # 1. Eigene IBAN in einer Spalte. + idents: dict[str, int] = {} + for acc_id, wert in db.query(AccountIdentifier.account_id, AccountIdentifier.value_norm).join( + Account, Account.id == AccountIdentifier.account_id + ).filter(Account.owner_id == user.id): + if wert: + idents[normalize_iban(wert)] = acc_id + breite = max(len(r) for r in proben) + for i in range(breite): + werte = {normalize_iban(r[i]) for r in proben if i < len(r) and r[i].strip()} + werte = {w for w in werte if len(w) >= 15} + if len(werte) == 1: + treffer = idents.get(next(iter(werte))) + if treffer: + return {"account_id": treffer, "grund": "eigene IBAN in der Datei", + "sicher": True} + + # 2. Währung plus zuletzt benutztes Konto derselben Bank. + waehrung_i = config["column_map"].get("currency") + waehrungen = set() + if waehrung_i is not None: + waehrungen = {r[waehrung_i].strip().upper() + for r in proben if waehrung_i < len(r) and r[waehrung_i].strip()} + if len(waehrungen) == 1: + w = next(iter(waehrungen)) + passend = [a for a in konten if a.currency.upper() == w] + if len(passend) == 1: + return {"account_id": passend[0].id, "grund": f"einziges Konto in {w}", "sicher": True} + if passend and erkannt.get("preset"): + frueher = { + b.account_id + for b in db.query(ImportBatch).filter( + ImportBatch.owner_id == user.id, + ImportBatch.preset == erkannt["preset"], + ImportBatch.status == ImportStatus.committed, + ) + } + treffer = [a for a in passend if a.id in frueher] + if len(treffer) == 1: + return {"account_id": treffer[0].id, + "grund": f"{w} und zuletzt für „{erkannt['preset']}“ benutzt", + "sicher": True} + + return {"account_id": None, + "grund": "Das Konto lässt sich aus der Datei nicht sicher ableiten"} + + # --- Kleine Parser-Helfer -------------------------------------------------- diff --git a/backend/tests/test_quick.py b/backend/tests/test_quick.py index 38f2ac2..99cb7fb 100644 --- a/backend/tests/test_quick.py +++ b/backend/tests/test_quick.py @@ -319,12 +319,10 @@ def test_fehler_am_kurzbefehl_sind_im_feld_message_lesbar(admin): kopf = {"X-Certain-Token": marke["token"]} import io - # Datei da, aber account_id vergessen: Pflichtfeld fehlt. - r = client.post("/api/quick/statement", - files={"file": ("a.csv", io.BytesIO(b"Datum;Betrag\n"), "text/csv")}, - headers=kopf) + # Gar keine Datei mitgeschickt: Pflichtfeld fehlt. + r = client.post("/api/quick/statement", data={"account_id": str(konto["id"])}, headers=kopf) assert r.status_code == 422 - assert "account_id" in r.json()["message"] + assert "file" in r.json()["message"] assert "Formular" in r.json()["message"] # Falsche Marke. @@ -339,3 +337,48 @@ def test_fehler_am_kurzbefehl_sind_im_feld_message_lesbar(admin): data={"account_id": str(konto["id"])}, headers=kopf) assert r.status_code == 400 assert "leer" in r.json()["message"].lower() + + +def test_konto_wird_aus_der_datei_erkannt(admin): + """Ein Kurzbefehl statt einer je Konto: Raiffeisen fuehrt die eigene IBAN + in der Datei mit, damit ist die Zuordnung eindeutig.""" + client = _fremd() + iban = "CH43 8080 8007 9160 7106 9" + konto = _konto(admin, name="Lohnkonto IBAN", currency="CHF", + identifiers=[{"kind": "iban", "value": iban}]) + # Zweites Konto derselben Waehrung, damit es etwas zu verwechseln gibt. + _konto(admin, name="Anderes CHF-Konto", currency="CHF") + marke = _marke(admin, None, name="Ohne Standardkonto " + secrets.token_hex(3)) + import io + + csv_text = ( + "IBAN;Booked At;Text;Credit/Debit Amount;Balance;Valuta Date\n" + "CH4380808007916071069;2026-01-05 00:00:00.0;LSV Viseca;-1718.7;702.47;2026-01-05 00:00:00.0\n" + "CH4380808007916071069;2026-01-06 00:00:00.0;Coop;-42.10;660.37;2026-01-06 00:00:00.0\n" + ) + r = client.post("/api/quick/statement", + files={"file": ("raiff.csv", io.BytesIO(csv_text.encode()), "text/csv")}, + headers={"X-Certain-Token": marke["token"]}) + assert r.status_code == 201, r.text + assert "IBAN" in r.json()["erkannt_als"] + + wartend = [u for u in admin.get("/api/imports/uploads").json() + if u["filename"] == "raiff.csv"] + assert wartend and wartend[0]["account_id"] == konto["id"] + + +def test_unklares_konto_nennt_die_moeglichkeiten(admin): + """Raten waere teuer: Ein Auszug im falschen Konto verfaelscht zwei Salden.""" + client = _fremd() + _konto(admin, name="Erstes Konto", currency="CHF") + _konto(admin, name="Zweites Konto", currency="CHF") + marke = _marke(admin, None, name="Ratlos " + secrets.token_hex(3)) + import io + + r = client.post("/api/quick/statement", + files={"file": ("x.csv", io.BytesIO( + b"Datum;Betrag;Text\n01.02.2026;-5,00;Kiosk\n"), "text/csv")}, + headers={"X-Certain-Token": marke["token"]}) + assert r.status_code == 400 + assert "account_id" in r.json()["message"] + assert "Erstes Konto" in r.json()["message"] diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 106428c..c692aff 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -497,11 +497,11 @@ "fileGuideTitle": "Kontoauszug vom Handy einlesen", "fileGuideIntro": "Revolut und Raiffeisen lassen die CSV auch am Telefon exportieren. Ein zweiter Kurzbefehl nimmt sie aus dem Teilen-Menü entgegen. Voraussetzung: Du hast für dieses Konto schon einmal am Rechner eingelesen – Certain verwendet dann dieselbe Spaltenzuordnung.", "fileStep1": "Neuer Kurzbefehl → oben auf den Namen tippen → „Im Teilen-Menü anzeigen“ einschalten, Eingabetyp: Dateien", - "fileStep2": "Für jedes Konto einen eigenen Kurzbefehl (z. B. „Certain: Revolut CHF“) – einfacher als eine Auswahl im Kurzbefehl. Die Nummer trägst du unten fest ein.", - "fileStep3": "Suche nach „URL“ → „Inhalte von URL abrufen“. Methode POST, Header X-Certain-Token = dein Geräteschlüssel, Anfragetext „Formular“ mit file = Kurzbefehleingabe und account_id = die Nummer deines Kontos:", + "fileStep2": "Kein Konto nötig: Certain erkennt es aus der Datei (Raiffeisen führt die eigene IBAN mit, Revolut die Währung). Nur wenn das nicht eindeutig ist, trägst du account_id ein – die Nummern stehen unten.", + "fileStep3": "Suche nach „URL“ → „Inhalte von URL abrufen“. Methode POST, Header X-Certain-Token = dein Geräteschlüssel, Anfragetext „Formular“ mit einem einzigen Feld: file = Kurzbefehleingabe.", "fileStep4": "„Mitteilung anzeigen“ – dann siehst du „14 gebucht · 512 schon vorhanden“", "fileGuideNote": "Gebucht wird sofort, weil ein Auszug hunderte Zeilen hat. Das ist ungefährlich: Bereits vorhandene Buchungen werden erkannt und übersprungen, und der ganze Stapel lässt sich im Import-Verlauf mit einem Klick zurücknehmen.", - "accountIds": "Konto-Nummern – eine davon kommt bei account_id hinein" + "accountIds": "Konto-Nummern – nur nötig, wenn die Erkennung nicht eindeutig ist" }, "inbox": { "title": "Eingang", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 3f0bcdf..c3e849d 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -497,11 +497,11 @@ "fileGuideTitle": "Import a statement from your phone", "fileGuideIntro": "Revolut and Raiffeisen let you export the CSV on the phone too. A second shortcut takes it from the share sheet. Prerequisite: you have imported for this account on a computer once – Certain then reuses that column mapping.", "fileStep1": "New shortcut → tap its name → enable “Show in Share Sheet”, input type: Files", - "fileStep2": "One shortcut per account (e.g. “Certain: Revolut CHF”) – simpler than a picker inside the shortcut. Enter the number below as a fixed value.", - "fileStep3": "Search for “URL” → “Get Contents of URL”. Method POST, header X-Certain-Token as above, request body “Form” with file = Shortcut Input and account_id = the chosen account:", + "fileStep2": "No account needed: Certain works it out from the file (Raiffeisen carries its own IBAN, Revolut the currency). Only add account_id when that is ambiguous – the numbers are below.", + "fileStep3": "Search for “URL” → “Get Contents of URL”. Method POST, header X-Certain-Token = your device key, request body “Form” with a single field: file = Shortcut Input.", "fileStep4": "“Show Notification” – you then see “14 booked · 512 already present”", "fileGuideNote": "It books straight away because a statement has hundreds of rows. That is safe: existing bookings are recognised and skipped, and the whole batch can be undone with one click in the import history.", - "accountIds": "Account numbers for account_id" + "accountIds": "Account numbers – only needed when detection is ambiguous" }, "inbox": { "title": "Captured",