Revolut und Raiffeisen lassen die CSV auch am Handy exportieren; nur N26 verlangt die Web-Version. Ein zweiter Kurzbefehl nimmt die Datei aus dem Teilen-Menue entgegen und schickt sie mit dem gewaehlten Konto an /quick/statement. Am Telefon laesst sich keine Spaltenzuordnung vornehmen. Der Endpunkt verwendet deshalb die Einstellung, mit der dieses Konto zuletzt erfolgreich eingelesen wurde – und sagt klar Bescheid, wenn es noch keine gibt, statt mit falschen Spalten zu raten. Gebucht wird sofort: Ein Auszug hat hunderte Zeilen, die einzeln abzunicken waere keine Erleichterung. Sicher ist das durch die Duplikaterkennung und weil der Stapel im Import-Verlauf mit einem Klick zurueckzunehmen ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
8.4 KiB
Python
231 lines
8.4 KiB
Python
"""Schnellerfassung für Geräte ohne Anmeldung (iOS-Kurzbefehle).
|
||
|
||
Der Kontoauszug kommt erst am Monatsende. Wer im Laden bezahlt hat, weiss in
|
||
dem Moment noch, wofür – auf dem Auszug steht später oft nur ein
|
||
nichtssagender Betreibername. Diese Endpunkte nehmen so eine Zahlung entgegen.
|
||
|
||
Die Anmeldung läuft NICHT über die Sitzung, sondern über eine Gerätemarke
|
||
(``require_quick_token``). Deren Geltungsbereich ist eng: erfassen ja, lesen
|
||
nein. Die einzige Ausnahme ist die Kontoliste, die der Kurzbefehl für seine
|
||
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
|
||
|
||
from ..database import get_db
|
||
from ..deps import require_quick_token
|
||
from ..models import (
|
||
Account,
|
||
ApiToken,
|
||
ImportBatch,
|
||
ImportStatus,
|
||
ImportTemplate,
|
||
InboxItem,
|
||
InboxSource,
|
||
InboxStatus,
|
||
Transaction,
|
||
TxnType,
|
||
User,
|
||
)
|
||
from ..schemas import InboxItemOut, QuickBookingIn, QuickBookingOut
|
||
from ..services import csv_import, currency
|
||
|
||
router = APIRouter(prefix="/quick", tags=["quick"])
|
||
|
||
|
||
def _konto(db: Session, marke: ApiToken, gewuenscht: int | None) -> Account:
|
||
"""Zielkonto bestimmen: ausdrücklich genannt, sonst das der Marke."""
|
||
konto_id = gewuenscht or marke.default_account_id
|
||
if konto_id is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Kein Konto angegeben und die Marke hat kein Standardkonto",
|
||
)
|
||
konto = db.get(Account, konto_id)
|
||
if konto is None or konto.owner_id != marke.owner_id:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Konto nicht gefunden")
|
||
return konto
|
||
|
||
|
||
@router.get("/accounts")
|
||
def quick_accounts(
|
||
marke: ApiToken = Depends(require_quick_token), db: Session = Depends(get_db)
|
||
) -> list[dict]:
|
||
"""Konten für die Auswahlliste im Kurzbefehl – ohne Salden."""
|
||
konten = (
|
||
db.query(Account)
|
||
.filter(Account.owner_id == marke.owner_id, Account.is_archived.is_(False))
|
||
.order_by(Account.position, Account.name)
|
||
.all()
|
||
)
|
||
return [
|
||
{"id": a.id, "name": a.name, "currency": a.currency, "icon": a.icon, "color": a.color,
|
||
"default": a.id == marke.default_account_id}
|
||
for a in konten
|
||
]
|
||
|
||
|
||
@router.post("/booking", response_model=QuickBookingOut, status_code=status.HTTP_201_CREATED)
|
||
def quick_booking(
|
||
payload: QuickBookingIn,
|
||
marke: ApiToken = Depends(require_quick_token),
|
||
db: Session = Depends(get_db),
|
||
) -> QuickBookingOut:
|
||
konto = _konto(db, marke, payload.account_id)
|
||
user = db.get(User, marke.owner_id)
|
||
tag = payload.date or date_t.today()
|
||
|
||
betrag = int(
|
||
(payload.amount.copy_abs() * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||
)
|
||
if betrag == 0:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Betrag ist null")
|
||
|
||
# Ein doppelt ausgelöster Kurzbefehl (schlechtes Netz, Doppeltipp) darf
|
||
# nicht zweimal buchen. Die bestehende Erfassung wird zurückgemeldet.
|
||
if payload.external_id:
|
||
schon = (
|
||
db.query(InboxItem)
|
||
.filter(InboxItem.owner_id == user.id, InboxItem.source == InboxSource.shortcut,
|
||
InboxItem.external_id == payload.external_id)
|
||
.first()
|
||
)
|
||
if schon is not None:
|
||
return QuickBookingOut(
|
||
message="Bereits erfasst", item=InboxItemOut.model_validate(schon)
|
||
)
|
||
|
||
# Durch die normale Erkennung schicken, damit Schlagwort- und Betragsregeln
|
||
# die Kategorie vorschlagen – dieselbe Strecke wie beim CSV-Import.
|
||
vorzeichen = -betrag if payload.kind == "expense" else betrag
|
||
erkannt = csv_import.detect_rows(db, user, konto, [{
|
||
"date": tag.isoformat(),
|
||
"amount_cents": vorzeichen,
|
||
"payee": payload.payee,
|
||
"purpose": payload.note,
|
||
}])
|
||
kategorie_id = erkannt[0].get("category_id") if erkannt else None
|
||
|
||
txn = Transaction(
|
||
owner_id=user.id,
|
||
type=TxnType.expense if payload.kind == "expense" else TxnType.income,
|
||
date=tag,
|
||
account_id=konto.id,
|
||
amount_cents=betrag,
|
||
amount_eur_cents=currency.compute_amount_eur_cents(db, betrag, konto.currency, tag),
|
||
category_id=kategorie_id,
|
||
payee=(payload.payee or None),
|
||
description=(payload.note or None),
|
||
)
|
||
db.add(txn)
|
||
db.flush()
|
||
|
||
eintrag = InboxItem(
|
||
owner_id=user.id,
|
||
source=InboxSource.shortcut,
|
||
status=InboxStatus.booked,
|
||
account_id=konto.id,
|
||
date=tag,
|
||
amount_cents=vorzeichen,
|
||
currency=konto.currency,
|
||
payee=(payload.payee or None),
|
||
note=(payload.note or None),
|
||
category_id=kategorie_id,
|
||
transaction_id=txn.id,
|
||
external_id=(payload.external_id or None),
|
||
)
|
||
db.add(eintrag)
|
||
db.commit()
|
||
db.refresh(eintrag)
|
||
|
||
wohin = f" · {payload.payee}" if payload.payee else ""
|
||
return QuickBookingOut(
|
||
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",
|
||
}
|