diff --git a/backend/app/models.py b/backend/app/models.py index 3b28a6f..fddf665 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -23,6 +23,7 @@ from sqlalchemy import ( Enum as SAEnum, ForeignKey, Integer, + LargeBinary, Numeric, String, Text, @@ -744,3 +745,27 @@ class MailSender(Base): ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + +class PendingUpload(Base): + """Eine vom Telefon abgelieferte Datei, die noch niemand geprüft hat. + + Am Telefon lässt sich ein Auszug nicht sinnvoll durchsehen – hunderte + Zeilen mit Kategorie-Auswahl sind auf dem Bildschirm keine Erleichterung. + Die Datei wird deshalb nur entgegengenommen; geprüft und gebucht wird + später in der normalen Import-Ansicht, mit Saldo-Abgleich und allem. + """ + + __tablename__ = "pending_uploads" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + account_id: Mapped[int] = mapped_column( + ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + filename: Mapped[str] = mapped_column(String(255), nullable=False, default="") + content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/routers/imports.py b/backend/app/routers/imports.py index bcf9e6f..7b6e3c9 100644 --- a/backend/app/routers/imports.py +++ b/backend/app/routers/imports.py @@ -7,7 +7,9 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned -from ..models import Account, AmountMode, ImportBatch, ImportStatus, ImportTemplate, User +from fastapi.responses import Response + +from ..models import PendingUpload, Account, AmountMode, ImportBatch, ImportStatus, ImportTemplate, User from ..schemas import ( ImportBatchOut, ImportCommitIn, @@ -179,3 +181,39 @@ def remove_batch_record( ) db.delete(batch) db.commit() + + +# --- Vom Telefon abgelieferte Dateien --------------------------------------- + + +@router.get("/uploads") +def list_uploads(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]: + """Wartende Dateien. Der Inhalt bleibt hier draussen – nur die Eckdaten.""" + zeilen = ( + db.query(PendingUpload) + .filter(PendingUpload.owner_id == user.id) + .order_by(PendingUpload.created_at.desc(), PendingUpload.id.desc()) + .all() + ) + return [ + {"id": u.id, "filename": u.filename, "account_id": u.account_id, + "size_bytes": u.size_bytes, "created_at": u.created_at.isoformat() if u.created_at else None} + for u in zeilen + ] + + +@router.get("/uploads/{upload_id}/content") +def upload_content(upload_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + """Die Datei selbst – damit die Import-Ansicht sie wie eine lokale behandelt.""" + upload = get_owned(db, PendingUpload, upload_id, user) + return Response( + content=upload.content, + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{upload.filename}"'}, + ) + + +@router.delete("/uploads/{upload_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_upload(upload_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + db.delete(get_owned(db, PendingUpload, upload_id, user)) + db.commit() diff --git a/backend/app/routers/inbox.py b/backend/app/routers/inbox.py index 0403782..5c26183 100644 --- a/backend/app/routers/inbox.py +++ b/backend/app/routers/inbox.py @@ -10,9 +10,18 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned -from ..models import Account, ApiToken, InboxItem, InboxStatus, Transaction, User +from ..models import ( + Account, + ApiToken, + InboxItem, + InboxStatus, + Transaction, + TxnType, + User, +) from ..schemas import ApiTokenCreate, ApiTokenCreated, ApiTokenOut, InboxItemOut from ..security import generate_session_token, hash_session_token +from ..services import currency router = APIRouter(prefix="/inbox", tags=["inbox"]) @@ -77,6 +86,68 @@ def list_inbox( ) +def _buche(db: Session, user: User, eintrag: InboxItem) -> Transaction | None: + """Aus einem Vorschlag eine Buchung machen. Ohne Konto geht es nicht.""" + if eintrag.status == InboxStatus.booked or eintrag.transaction_id is not None: + return None + if eintrag.account_id is None: + return None + konto = db.get(Account, eintrag.account_id) + if konto is None or konto.owner_id != user.id: + return None + betrag = abs(eintrag.amount_cents) + txn = Transaction( + owner_id=user.id, + type=TxnType.expense if eintrag.amount_cents < 0 else TxnType.income, + date=eintrag.date, + account_id=konto.id, + amount_cents=betrag, + amount_eur_cents=currency.compute_amount_eur_cents( + db, betrag, konto.currency, eintrag.date), + category_id=eintrag.category_id, + payee=eintrag.payee, + description=eintrag.note, + ) + db.add(txn) + db.flush() + eintrag.transaction_id = txn.id + eintrag.status = InboxStatus.booked + return txn + + +@router.post("/{item_id}/book", response_model=InboxItemOut) +def book_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + eintrag = get_owned(db, InboxItem, item_id, user) + if eintrag.account_id is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Kein Konto zugeordnet") + if _buche(db, user, eintrag) is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Lässt sich nicht buchen") + db.commit() + db.refresh(eintrag) + return eintrag + + +@router.post("/book-all") +def book_all(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + """Alle offenen Vorschläge buchen, die ein Konto haben. + + Unklare bleiben liegen: Bei ihnen fehlt etwas oder die Erkennung war + unsicher – die soll man ansehen, nicht im Stapel durchwinken. + """ + offen = ( + db.query(InboxItem) + .filter(InboxItem.owner_id == user.id, + InboxItem.status == InboxStatus.pending, + InboxItem.account_id.isnot(None)) + .all() + ) + gebucht = sum(1 for e in offen if _buche(db, user, e) is not None) + db.commit() + return {"gebucht": gebucht} + + @router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) def discard_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Eintrag verwerfen – samt der Buchung, die daraus entstanden ist. diff --git a/backend/app/routers/quick.py b/backend/app/routers/quick.py index c101356..1ef69f5 100644 --- a/backend/app/routers/quick.py +++ b/backend/app/routers/quick.py @@ -13,8 +13,6 @@ 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 -import json - from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from sqlalchemy.orm import Session @@ -23,13 +21,11 @@ from ..deps import require_quick_token from ..models import ( Account, ApiToken, - ImportBatch, - ImportStatus, - ImportTemplate, InboxItem, InboxSource, InboxStatus, Transaction, + PendingUpload, TxnType, User, ) @@ -151,34 +147,8 @@ def quick_booking( ) -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 +# Grosszuegig, aber nicht unbegrenzt: Ein Jahresauszug liegt weit darunter. +MAX_UPLOAD = 5 * 1024 * 1024 @router.post("/statement", status_code=status.HTTP_201_CREATED) @@ -188,43 +158,37 @@ async def quick_statement( marke: ApiToken = Depends(require_quick_token), db: Session = Depends(get_db), ) -> dict: - """Kontoauszug vom Telefon aus einlesen (Teilen-Menü). + """Kontoauszug vom Telefon entgegennehmen (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. + Hier wird NICHT gebucht. Ein Auszug hat schnell hunderte Zeilen; die am + Telefon durchzusehen wäre keine Erleichterung. Die Datei wird abgelegt und + 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) - user = db.get(User, marke.owner_id) - vorlage = _letzte_vorlage(db, konto) - if vorlage is None: + daten = await file.read() + if not daten: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Datei ist leer") + if len(daten) > MAX_UPLOAD: 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."), + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"Datei zu gross ({len(daten) // 1024} KB, erlaubt sind " + f"{MAX_UPLOAD // 1024 // 1024} MB)", ) - 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, + upload = PendingUpload( + owner_id=marke.owner_id, + account_id=konto.id, + filename=(file.filename or "Vom Telefon")[:255], + content=daten, + size_bytes=len(daten), ) - dubletten = sum(1 for z in zeilen if z.get("is_duplicate")) + db.add(upload) + db.commit() + db.refresh(upload) return { - "gelesen": len(zeilen), - "gebucht": stapel.imported_count, - "duplikate": dubletten, + "id": upload.id, "konto": konto.name, - "message": f"{stapel.imported_count} gebucht auf {konto.name}" - f" · {dubletten} schon vorhanden", + "size_bytes": upload.size_bytes, + "message": f"Empfangen für {konto.name} – zum Prüfen in Certain öffnen", } diff --git a/backend/migrations/versions/0019_pending_uploads.py b/backend/migrations/versions/0019_pending_uploads.py new file mode 100644 index 0000000..f9bc2da --- /dev/null +++ b/backend/migrations/versions/0019_pending_uploads.py @@ -0,0 +1,38 @@ +"""Vom Telefon abgelieferte Dateien, die noch niemand geprueft hat. + +Am Telefon laesst sich ein Auszug mit hunderten Zeilen nicht sinnvoll +durchsehen. Die Datei wird deshalb nur entgegengenommen und spaeter in der +normalen Import-Ansicht geprueft – mit Vorschau, Saldo-Abgleich und der +Moeglichkeit, einzelne Zeilen abzuwaehlen. + +Revision ID: 0019 +Revises: 0018 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0019" +down_revision = "0018" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "pending_uploads", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("filename", sa.String(length=255), nullable=False, server_default=""), + sa.Column("content", sa.LargeBinary(), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="CASCADE"), + ) + op.create_index("ix_pending_uploads_owner_id", "pending_uploads", ["owner_id"]) + + +def downgrade() -> None: + op.drop_table("pending_uploads") diff --git a/backend/tests/test_mail_import.py b/backend/tests/test_mail_import.py index b029caf..a301818 100644 --- a/backend/tests/test_mail_import.py +++ b/backend/tests/test_mail_import.py @@ -399,3 +399,48 @@ def test_zuordnung_mit_waehrung_schlaegt_die_allgemeine(admin, monkeypatch): treffer = [e for e in admin.get("/api/inbox").json() if e["amount_cents"] == -1200] assert treffer and treffer[0]["account_id"] == eur["id"] + + +def test_mailvorschlag_wird_erst_auf_knopfdruck_gebucht(admin, fake_imap): + """Eine Mail bucht nie von selbst – aber sie muss sich buchen lassen.""" + konto = _konto(admin, name="Buchbar", opening_balance_cents=0) + quelle = _postfach(admin) + admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ + "from_contains": "raiffeisen", "account_id": konto["id"]}) + + roh = (MUSTER / "raiffeisen_zahlungsstatus.eml").read_bytes().replace( + b" request(`/inbox/${id}`, { method: "DELETE" }), setInboxCategory: (id, categoryId) => request(`/inbox/${id}/category/${categoryId}`, { method: "POST" }), + bookInboxItem: (id) => request(`/inbox/${id}/book`, { method: "POST" }), + bookAllInbox: () => request("/inbox/book-all", { method: "POST" }), + listUploads: () => request("/imports/uploads"), + deleteUpload: (id) => request(`/imports/uploads/${id}`, { method: "DELETE" }), // Postfächer für den Mail-Abruf listMailSources: () => request("/mail/sources"), diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 3ab99ab..e1fb48f 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -368,7 +368,13 @@ "removeAllUndone": "Rückgängig gemachte entfernen", "removeAllUndoneConfirm": "{{n}} rückgängig gemachte Einträge aus dem Verlauf entfernen? Die Buchungen sind bereits weg, es verschwindet nur der Eintrag.", "dupInbox": "schon erfasst", - "dupInboxHint": "Diese Zahlung hast du unterwegs per Kurzbefehl erfasst – Betrag gleich, Datum in der Nähe. Sie ist bereits gebucht und wird deshalb übersprungen. Falls das doch zwei verschiedene Zahlungen sind, kannst du die Zeile von Hand anhaken." + "dupInboxHint": "Diese Zahlung hast du unterwegs per Kurzbefehl erfasst – Betrag gleich, Datum in der Nähe. Sie ist bereits gebucht und wird deshalb übersprungen. Falls das doch zwei verschiedene Zahlungen sind, kannst du die Zeile von Hand anhaken.", + "fromPhone": "Vom Telefon empfangen", + "fromPhoneHint": "Diese Dateien hast du per Kurzbefehl geschickt. Gebucht ist noch nichts – übernimm eine, dann läuft sie durch die normale Vorschau mit Saldo-Abgleich.", + "usePhoneFile": "Übernehmen", + "phoneFileLoaded": "„{{name}}“ geladen – jetzt Format prüfen und Vorschau erzeugen.", + "discardPhoneFile": "Datei verwerfen", + "discardPhoneFileConfirm": "„{{name}}“ verwerfen? Die Datei wird gelöscht, gebucht war ohnehin nichts." }, "password": { "show": "Passwort anzeigen", @@ -499,7 +505,13 @@ "noText": "ohne Text", "viaShortcut": "per Kurzbefehl", "discard": "Verwerfen", - "discardConfirm": "Diese Erfassung verwerfen? Die Buchung wird gelöscht. Der Eintrag bleibt als verworfen vermerkt, damit der Kontoauszug die Zahlung später trotzdem erkennt." + "discardConfirm": "Diese Erfassung verwerfen? Die Buchung wird gelöscht. Der Eintrag bleibt als verworfen vermerkt, damit der Kontoauszug die Zahlung später trotzdem erkennt.", + "book": "Buchen", + "bookAll": "Alle buchen", + "bookedN": "{{n}} gebucht.", + "booked": "gebucht", + "unclear": "unklar – bitte ansehen", + "viaMail": "aus einer E-Mail" }, "mailbox": { "title": "Postfach für Zahlungs-Mails", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index b2ff28a..e9d9ab3 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -368,7 +368,13 @@ "removeAllUndone": "Remove undone entries", "removeAllUndoneConfirm": "Remove {{n}} undone entries from the history? The bookings are already gone; only the entry disappears.", "dupInbox": "already captured", - "dupInboxHint": "You recorded this payment on the go via shortcut – same amount, nearby date. It is already booked, so it is skipped. If these really are two separate payments, tick the row by hand." + "dupInboxHint": "You recorded this payment on the go via shortcut – same amount, nearby date. It is already booked, so it is skipped. If these really are two separate payments, tick the row by hand.", + "fromPhone": "Received from your phone", + "fromPhoneHint": "You sent these via shortcut. Nothing is booked yet – take one over and it runs through the normal preview with balance check.", + "usePhoneFile": "Take over", + "phoneFileLoaded": "“{{name}}” loaded – check the format and build the preview.", + "discardPhoneFile": "Discard file", + "discardPhoneFileConfirm": "Discard “{{name}}”? The file is deleted; nothing was booked anyway." }, "password": { "show": "Show password", @@ -499,7 +505,13 @@ "noText": "no text", "viaShortcut": "via shortcut", "discard": "Discard", - "discardConfirm": "Discard this capture? The booking is deleted. The entry stays marked as discarded so the statement still recognises the payment later." + "discardConfirm": "Discard this capture? The booking is deleted. The entry stays marked as discarded so the statement still recognises the payment later.", + "book": "Book", + "bookAll": "Book all", + "bookedN": "{{n}} booked.", + "booked": "booked", + "unclear": "unclear – please review", + "viaMail": "from an email" }, "mailbox": { "title": "Mailbox for payment emails", diff --git a/web/src/pages/Eingang.jsx b/web/src/pages/Eingang.jsx index cfa2314..2b85c98 100644 --- a/web/src/pages/Eingang.jsx +++ b/web/src/pages/Eingang.jsx @@ -54,6 +54,25 @@ export default function Eingang() { } } + async function buchen(item) { + try { + await api.bookInboxItem(item.id); + laden(); + } catch (e) { + toast(e.message, "error"); + } + } + + async function alleBuchen() { + try { + const r = await api.bookAllInbox(); + toast(t("inbox.bookedN", { n: r.gebucht })); + laden(); + } catch (e) { + toast(e.message, "error"); + } + } + async function verwerfen(item) { if (!(await confirm({ title: t("inbox.discard"), @@ -76,6 +95,11 @@ export default function Eingang() {

{t("inbox.title")}

{t("inbox.intro")}

+ {rows.some((r) => r.status === "pending" && r.account_id) && ( + + )}
{rows.length === 0 ? ( @@ -94,6 +118,9 @@ export default function Eingang() {
{formatDate(item.date)} · {konto?.name || "—"} {item.source === "shortcut" && ` · ${t("inbox.viaShortcut")}`} + {item.source === "mail" && ` · ${t("inbox.viaMail")}`} + {item.status === "unclear" && ` · ${t("inbox.unclear")}`} + {item.status === "booked" && ` · ${t("inbox.booked")}`}
zuordnen(item, id)} /> + {item.status !== "booked" && item.account_id && ( + + )} + + + + ))} + + )} + {/* Verlauf */} {batches.length > 0 && (