diff --git a/backend/app/routers/quick.py b/backend/app/routers/quick.py index 98d40b4..c101356 100644 --- a/backend/app/routers/quick.py +++ b/backend/app/routers/quick.py @@ -13,7 +13,9 @@ Auswahl braucht – sie liefert nur Name, Symbol und Farbe, keine Salden. from datetime import date as date_t from decimal import ROUND_HALF_UP, Decimal -from fastapi import APIRouter, Depends, HTTPException, status +import json + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from sqlalchemy.orm import Session from ..database import get_db @@ -21,6 +23,9 @@ from ..deps import require_quick_token from ..models import ( Account, ApiToken, + ImportBatch, + ImportStatus, + ImportTemplate, InboxItem, InboxSource, InboxStatus, @@ -144,3 +149,82 @@ def quick_booking( message=f"{betrag / 100:.2f} {konto.currency} auf {konto.name}{wohin}", item=InboxItemOut.model_validate(eintrag), ) + + +def _letzte_vorlage(db: Session, konto: Account) -> ImportTemplate | None: + """Die Einstellung, mit der dieses Konto zuletzt erfolgreich eingelesen wurde. + + Am Telefon lässt sich keine Spaltenzuordnung vornehmen. Der Kurzbefehl + setzt deshalb voraus, dass die Datei am Rechner schon einmal für dieses + Konto eingelesen wurde – dann steht im Import-Verlauf, wie es ging. + """ + stapel = ( + db.query(ImportBatch) + .filter(ImportBatch.owner_id == konto.owner_id, + ImportBatch.account_id == konto.id, + ImportBatch.status == ImportStatus.committed) + .order_by(ImportBatch.created_at.desc(), ImportBatch.id.desc()) + .first() + ) + if stapel is None: + return None + if stapel.template_id: + vorlage = db.get(ImportTemplate, stapel.template_id) + if vorlage is not None and vorlage.owner_id == konto.owner_id: + return vorlage + if stapel.preset: + for p in csv_import.PRESETS: + if p["name"] == stapel.preset: + felder = {k: v for k, v in p.items() if k not in ("name", "column_map")} + return ImportTemplate(owner_id=konto.owner_id, name="_kurzbefehl", + column_map=json.dumps(p["column_map"]), **felder) + return None + + +@router.post("/statement", status_code=status.HTTP_201_CREATED) +async def quick_statement( + file: UploadFile = File(...), + account_id: int = Form(...), + marke: ApiToken = Depends(require_quick_token), + db: Session = Depends(get_db), +) -> dict: + """Kontoauszug vom Telefon aus einlesen (Teilen-Menü). + + Gebucht wird sofort – ein Auszug hat schnell hunderte Zeilen, die einzeln + abzunicken wäre keine Erleichterung. Sicher ist das, weil die + Duplikaterkennung greift und der ganze Stapel im Import-Verlauf mit einem + Klick rückgängig zu machen ist. + """ + konto = _konto(db, marke, account_id) + user = db.get(User, marke.owner_id) + vorlage = _letzte_vorlage(db, konto) + if vorlage is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=(f"Für „{konto.name}“ gibt es noch keine Einstellung. Bitte einen Auszug " + "dieses Kontos einmal am Rechner einlesen – danach geht es vom Telefon."), + ) + + daten = await file.read() + try: + kandidaten = csv_import.parse_file_to_candidates(daten, vorlage) + except Exception as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Datei nicht lesbar: {e}") from e + + zeilen = csv_import.detect_rows(db, user, konto, kandidaten) + zu_buchen = [z for z in zeilen if z.get("include")] + stapel = csv_import.commit_import( + db, user, konto, vorlage.id if vorlage.id else None, + (file.filename or "Vom Telefon")[:255], zu_buchen, + preset=None if vorlage.id else vorlage.name, + ) + dubletten = sum(1 for z in zeilen if z.get("is_duplicate")) + return { + "gelesen": len(zeilen), + "gebucht": stapel.imported_count, + "duplikate": dubletten, + "konto": konto.name, + "message": f"{stapel.imported_count} gebucht auf {konto.name}" + f" · {dubletten} schon vorhanden", + } diff --git a/backend/tests/test_quick.py b/backend/tests/test_quick.py index 362527c..16788d2 100644 --- a/backend/tests/test_quick.py +++ b/backend/tests/test_quick.py @@ -247,3 +247,61 @@ def test_zwei_gleiche_zahlungen_verbrauchen_je_einen_eintrag(admin): assert gruende.count("inbox") == 2, gruende # Die dritte ist echt und wird gebucht. assert sum(1 for r in v["rows"] if r["include"]) == 1 + + +def test_auszug_vom_telefon_nutzt_die_zuletzt_benutzte_vorlage(admin): + """Am Telefon kann man keine Spalten zuordnen. Also wird die Einstellung + wiederverwendet, mit der dieses Konto zuletzt eingelesen wurde.""" + client = _fremd() + konto = _konto(admin, name="Handykonto", currency="CHF") + marke = _marke(admin, konto["id"]) + kopf = {"X-Certain-Token": marke["token"]} + import io + + csv_text = KOPF + "01.04.2026;-12,00;Bäckerei;Brot;\n02.04.2026;-8,00;Kiosk;Zeitung;\n" + + # Ohne vorherigen Import am Rechner: klare Ansage statt Rätselraten. + r = client.post("/api/quick/statement", + files={"file": ("a.csv", io.BytesIO(csv_text.encode()), "text/csv")}, + data={"account_id": str(konto["id"])}, headers=kopf) + assert r.status_code == 400 + assert "am Rechner" in r.json()["detail"] + + # Einmal am Rechner einlesen legt die Einstellung fest. + import json as _json + vorlage = admin.post("/api/imports/templates", json={ + "name": "Handytest " + secrets.token_hex(3), "delimiter": ";", "decimal_sep": ",", + "date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": True, "skip_rows": 0, + "amount_mode": "signed", + "column_map": {"date": 0, "amount": 1, "payee": 2, "purpose": 3}, + }) + assert vorlage.status_code == 201, vorlage.text + v = _import(admin, konto["id"], csv_text) + admin.post("/api/imports/commit", json={ + "account_id": konto["id"], "filename": "april.csv", + "template_id": vorlage.json()["id"], + "rows": [x for x in v["rows"] if x["include"]]}) + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 2000 + + # Ab jetzt geht es vom Telefon: neue Zeilen kommen an, alte nicht doppelt. + mehr = csv_text + "03.04.2026;-5,50;Coop;Milch;\n" + r = client.post("/api/quick/statement", + files={"file": ("april.csv", io.BytesIO(mehr.encode()), "text/csv")}, + data={"account_id": str(konto["id"])}, headers=kopf) + assert r.status_code == 201, r.text + assert r.json()["gebucht"] == 1 + assert r.json()["duplikate"] == 2 + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 2550 + + # Und der Stapel steht im Verlauf, also mit einem Klick ruecknehmbar. + stapel = admin.get("/api/imports/batches").json() + assert any(b["filename"] == "april.csv" and b["imported_count"] == 1 for b in stapel) + + +def test_auszug_vom_telefon_braucht_eine_gueltige_marke(admin): + import io + konto = _konto(admin, name="Fremdes Konto") + r = _fremd().post("/api/quick/statement", + files={"file": ("a.csv", io.BytesIO(b"x"), "text/csv")}, + data={"account_id": str(konto["id"])}) + assert r.status_code == 401 diff --git a/web/src/locales/de.json b/web/src/locales/de.json index c575f1e..3ab99ab 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -483,7 +483,14 @@ "revokeConfirm": "Marke „{{name}}\" widerrufen? Der Kurzbefehl auf dem Gerät funktioniert danach nicht mehr. Bereits erfasste Buchungen bleiben.", "noDefault": "kein Standardkonto", "lastUsed": "schon benutzt", - "neverUsed": "noch nie benutzt" + "neverUsed": "noch nie benutzt", + "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": "Suche nach „Liste“ → „Aus Liste wählen“ für das Konto (die Konto-Nummern findest du über die Adresse /api/quick/accounts)", + "fileStep3": "Suche nach „URL“ → „Inhalte von URL abrufen“. Methode POST, Header X-Certain-Token wie oben, Anfragetext „Formular“ mit file = Kurzbefehleingabe und account_id = das gewählte Konto:", + "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." }, "inbox": { "title": "Eingang", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index b042e38..b2ff28a 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -483,7 +483,14 @@ "revokeConfirm": "Revoke token “{{name}}”? The shortcut on that device will stop working. Bookings already recorded stay.", "noDefault": "no default account", "lastUsed": "used before", - "neverUsed": "never used" + "neverUsed": "never used", + "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": "Search for “List” → “Choose from List” for the account (account ids are at /api/quick/accounts)", + "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:", + "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." }, "inbox": { "title": "Captured", diff --git a/web/src/pages/settings/Geraetemarken.jsx b/web/src/pages/settings/Geraetemarken.jsx index 3440588..97c2fc2 100644 --- a/web/src/pages/settings/Geraetemarken.jsx +++ b/web/src/pages/settings/Geraetemarken.jsx @@ -154,6 +154,27 @@ export default function Geraetemarken() {
{t("tokens.guideHomeScreen")}
{t("tokens.guideAutomation")}
+ +{t("tokens.fileGuideIntro")}
+{t("tokens.fileGuideNote")}
+