feat: Konto wird aus der Datei erkannt – ein Kurzbefehl genuegt

Bisher brauchte es je Konto einen eigenen Kurzbefehl, weil account_id
Pflicht war. Jetzt ist sie optional: Raiffeisen fuehrt die eigene IBAN in der
Datei mit (Treffer gegen die Erkennungs-Texte der Konten), Revolut die
Waehrung – zusammen mit der erkannten Bank und dem Import-Verlauf reicht das.

Bleibt es mehrdeutig, wird NICHT geraten: Ein Auszug im falschen Konto
verfaelscht zwei Salden auf einmal. Stattdessen nennt die Fehlermeldung die
Konten samt Nummern, sodass man sie im Kurzbefehl nachtragen kann. Hat der
Schluessel ein Standardkonto, gilt das.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 23:54:27 +02:00
co-authored by Claude Opus 5
parent 1b401ff358
commit e8a862bd63
5 changed files with 161 additions and 14 deletions
+30 -3
View File
@@ -154,7 +154,7 @@ MAX_UPLOAD = 5 * 1024 * 1024
@router.post("/statement", status_code=status.HTTP_201_CREATED) @router.post("/statement", status_code=status.HTTP_201_CREATED)
async def quick_statement( async def quick_statement(
file: UploadFile = File(...), file: UploadFile = File(...),
account_id: int = Form(...), account_id: int | None = Form(None),
marke: ApiToken = Depends(require_quick_token), marke: ApiToken = Depends(require_quick_token),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@@ -165,7 +165,6 @@ async def quick_statement(
wartet in der Import-Ansicht – dort gibt es Vorschau, Saldo-Abgleich und wartet in der Import-Ansicht – dort gibt es Vorschau, Saldo-Abgleich und
die Möglichkeit, einzelne Zeilen abzuwählen. die Möglichkeit, einzelne Zeilen abzuwählen.
""" """
konto = _konto(db, marke, account_id)
daten = await file.read() daten = await file.read()
if not daten: if not daten:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Datei ist leer") 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)", 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( upload = PendingUpload(
owner_id=marke.owner_id, owner_id=marke.owner_id,
account_id=konto.id, account_id=konto.id,
@@ -190,5 +216,6 @@ async def quick_statement(
"id": upload.id, "id": upload.id,
"konto": konto.name, "konto": konto.name,
"size_bytes": upload.size_bytes, "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",
} }
+77
View File
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from ..models import ( from ..models import (
Account, Account,
AccountIdentifier,
InboxItem, InboxItem,
InboxStatus, InboxStatus,
AmountMode, 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 -------------------------------------------------- # --- Kleine Parser-Helfer --------------------------------------------------
+48 -5
View File
@@ -319,12 +319,10 @@ def test_fehler_am_kurzbefehl_sind_im_feld_message_lesbar(admin):
kopf = {"X-Certain-Token": marke["token"]} kopf = {"X-Certain-Token": marke["token"]}
import io import io
# Datei da, aber account_id vergessen: Pflichtfeld fehlt. # Gar keine Datei mitgeschickt: Pflichtfeld fehlt.
r = client.post("/api/quick/statement", r = client.post("/api/quick/statement", data={"account_id": str(konto["id"])}, headers=kopf)
files={"file": ("a.csv", io.BytesIO(b"Datum;Betrag\n"), "text/csv")},
headers=kopf)
assert r.status_code == 422 assert r.status_code == 422
assert "account_id" in r.json()["message"] assert "file" in r.json()["message"]
assert "Formular" in r.json()["message"] assert "Formular" in r.json()["message"]
# Falsche Marke. # 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) data={"account_id": str(konto["id"])}, headers=kopf)
assert r.status_code == 400 assert r.status_code == 400
assert "leer" in r.json()["message"].lower() 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"]
+3 -3
View File
@@ -497,11 +497,11 @@
"fileGuideTitle": "Kontoauszug vom Handy einlesen", "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.", "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", "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.", "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 file = Kurzbefehleingabe und account_id = die Nummer deines Kontos:", "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“", "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.", "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": { "inbox": {
"title": "Eingang", "title": "Eingang",
+3 -3
View File
@@ -497,11 +497,11 @@
"fileGuideTitle": "Import a statement from your phone", "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.", "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", "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.", "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 as above, request body “Form” with file = Shortcut Input and account_id = the chosen account:", "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”", "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.", "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": { "inbox": {
"title": "Captured", "title": "Captured",