feat: Schnellerfassung per Kurzbefehl (Etappe A)

Der Kontoauszug kommt erst am Monatsende; wer im Laden bezahlt hat, weiss
in dem Moment noch, wofuer. iOS laesst keine App fremde Mitteilungen
mitlesen, also fuehrt der Weg ueber einen Kurzbefehl, der die Zahlung an
Certain schickt.

Gebaut:
- Geraetemarken mit eigener Abhaengigkeit statt Sonderfall in
  get_current_user. Eine Marke gilt NUR an /quick – auf allen anderen
  Wegen ist sie keine Anmeldung. Geht das Telefon verloren, ist kein
  Datenabfluss moeglich; Tests halten das fuer Buchungen, Konten,
  Kategorien, Export und Datenbanksicherung fest.
- POST /quick/booking laeuft durch detect_rows, damit Schlagwort- und
  Betragsregeln die Kategorie schon vorschlagen. external_id verhindert,
  dass ein doppelt ausgeloester Kurzbefehl zweimal bucht.
- inbox_items merkt sich die Zahlung ueber das Buchen hinaus. Beim
  spaeteren CSV-Import greift der Import-Schluessel nicht: Der Text vom
  Telefon ist selten wortgleich mit dem des Auszugs und die Bank bucht ein
  bis zwei Tage spaeter. detect_rows vergleicht deshalb zusaetzlich
  unscharf – exakter Betrag, Datum +/- 3 Tage, je Eintrag nur einmal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 19:34:14 +02:00
co-authored by Claude Opus 5
parent 9d6a53a224
commit 02260d7cd2
9 changed files with 841 additions and 1 deletions
+243
View File
@@ -0,0 +1,243 @@
"""Schnellerfassung per Gerätemarke (iOS-Kurzbefehl)."""
from fastapi.testclient import TestClient
def _fremd():
"""Client OHNE Sitzung. Die Fixture ``client`` ist derselbe Client wie
``admin`` und traegt dessen Anmeldung – damit liesse sich nicht pruefen,
ob eine Marke fuer sich allein etwas darf."""
from app.main import app
return TestClient(app)
def _konto(admin, **kw):
body = {"name": "Girokonto", "type": "girokonto", "currency": "CHF",
"opening_balance_cents": 100000}
body.update(kw)
r = admin.post("/api/accounts", json=body)
assert r.status_code == 201, r.text
return r.json()
def _marke(admin, konto_id=None, name="iPhone"):
r = admin.post("/api/inbox/tokens", json={"name": name, "default_account_id": konto_id})
assert r.status_code == 201, r.text
return r.json()
def _kategorie(admin, name):
for g in admin.get("/api/categories").json():
for c in g["categories"]:
if c["name"] == name:
return c["id"]
raise AssertionError(f"{name} fehlt")
def test_marke_bucht_und_taucht_im_eingang_auf(admin):
client = _fremd()
konto = _konto(admin)
marke = _marke(admin, konto["id"])
r = client.post("/api/quick/booking", json={"amount": "12.50", "payee": "Kantine SV"},
headers={"X-Certain-Token": marke["token"]})
assert r.status_code == 201, r.text
assert "12.50 CHF" in r.json()["message"]
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 1250
meine = [e for e in admin.get("/api/inbox").json() if e["payee"] == "Kantine SV"]
assert len(meine) == 1
assert meine[0]["status"] == "booked"
def test_marke_darf_nur_erfassen_nichts_lesen(admin):
"""Die Marke liegt auf dem Telefon. Sie darf keine Daten herausgeben."""
client = _fremd()
konto = _konto(admin)
marke = _marke(admin, konto["id"])
kopf = {"X-Certain-Token": marke["token"]}
for pfad in ("/api/transactions", "/api/accounts", "/api/inbox", "/api/export/all.json", "/api/backup/db", "/api/categories"):
r = client.get(pfad, headers=kopf)
assert r.status_code == 401, f"{pfad} gab {r.status_code} statt 401"
def test_unbekannte_oder_fehlende_marke_wird_abgewiesen(admin):
client = _fremd()
_konto(admin)
assert client.post("/api/quick/booking", json={"amount": "5"}).status_code == 401
r = client.post("/api/quick/booking", json={"amount": "5"},
headers={"X-Certain-Token": "ausgedacht"})
assert r.status_code == 401
def test_widerrufene_marke_bucht_nicht_mehr(admin):
client = _fremd()
konto = _konto(admin)
marke = _marke(admin, konto["id"])
kopf = {"X-Certain-Token": marke["token"]}
assert client.post("/api/quick/booking", json={"amount": "1"}, headers=kopf).status_code == 201
assert admin.delete(f"/api/inbox/tokens/{marke['id']}").status_code == 204
assert client.post("/api/quick/booking", json={"amount": "1"}, headers=kopf).status_code == 401
def test_klartext_der_marke_ist_nur_einmal_zu_sehen(admin):
konto = _konto(admin)
marke = _marke(admin, konto["id"])
assert marke["token"]
liste = admin.get("/api/inbox/tokens").json()
meine = [t for t in liste if t["id"] == marke["id"]]
assert len(meine) == 1
assert "token" not in meine[0], "Der Klartext darf nie wieder ausgeliefert werden"
def test_doppelt_ausgeloester_kurzbefehl_bucht_nur_einmal(admin):
"""Schlechtes Netz oder Doppeltipp dürfen nicht zweimal abbuchen."""
client = _fremd()
konto = _konto(admin)
marke = _marke(admin, konto["id"])
kopf = {"X-Certain-Token": marke["token"]}
daten = {"amount": "9.90", "payee": "Kiosk", "external_id": "abc-123"}
a = client.post("/api/quick/booking", json=daten, headers=kopf)
b = client.post("/api/quick/booking", json=daten, headers=kopf)
assert a.status_code == 201 and b.status_code == 201
assert b.json()["message"] == "Bereits erfasst"
assert a.json()["item"]["id"] == b.json()["item"]["id"]
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 990
def test_schlagwort_schlaegt_die_kategorie_vor(admin):
"""Was der CSV-Import kann, kann der Kurzbefehl auch."""
client = _fremd()
konto = _konto(admin)
leb = _kategorie(admin, "Lebensmittel")
assert admin.post(f"/api/categories/{leb}/keywords", json={"word": "coop"}).status_code == 201
marke = _marke(admin, konto["id"])
r = client.post("/api/quick/booking", json={"amount": "23.40", "payee": "COOP Bahnhof"},
headers={"X-Certain-Token": marke["token"]})
assert r.status_code == 201, r.text
assert r.json()["item"]["category_id"] == leb
def test_einnahme_und_fremdes_konto(admin):
client = _fremd()
konto = _konto(admin)
zweites = _konto(admin, name="Sparkonto", opening_balance_cents=0)
marke = _marke(admin, konto["id"])
kopf = {"X-Certain-Token": marke["token"]}
r = client.post("/api/quick/booking",
json={"amount": "50", "kind": "income", "account_id": zweites["id"]},
headers=kopf)
assert r.status_code == 201, r.text
assert admin.get(f"/api/accounts/{zweites['id']}").json()["balance_cents"] == 5000
# Das Standardkonto bleibt unberührt.
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000
def test_verwerfen_loescht_die_buchung_behaelt_aber_die_spur(admin):
client = _fremd()
konto = _konto(admin)
marke = _marke(admin, konto["id"])
client.post("/api/quick/booking", json={"amount": "7.00", "payee": "Irrtum"},
headers={"X-Certain-Token": marke["token"]})
eintrag = admin.get("/api/inbox").json()[0]
assert admin.delete(f"/api/inbox/{eintrag['id']}").status_code == 204
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000
assert not [e for e in admin.get("/api/inbox").json() if e["id"] == eintrag["id"]]
def _import(admin, konto_id, csv_text):
import io
import json
return admin.post(
"/api/imports/preview",
files={"file": ("a.csv", io.BytesIO(csv_text.encode("utf-8")), "text/csv")},
data={"account_id": str(konto_id), "delimiter": ";", "decimal_sep": ",",
"date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": "true",
"skip_rows": "0", "amount_mode": "signed",
"column_map": json.dumps({"date": 0, "amount": 1, "payee": 2, "purpose": 3})},
).json()
KOPF = "Datum;Betrag;Empfaenger;Zweck\n"
def test_auszug_erkennt_die_bereits_per_kurzbefehl_erfasste_zahlung(admin):
"""Der Kern: dieselbe Zahlung darf nicht zweimal in den Saldo laufen.
Der Import-Schlüssel greift hier nicht – auf dem Auszug steht ein anderer
Text als das, was man am Telefon eingetippt hat, und die Bank bucht ein
bis zwei Tage später.
"""
client = _fremd()
konto = _konto(admin)
marke = _marke(admin, konto["id"])
client.post("/api/quick/booking",
json={"amount": "12.50", "payee": "Kantine", "date": "2026-05-04"},
headers={"X-Certain-Token": marke["token"]})
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 1250
# Der Auszug nennt sie anders und datiert sie zwei Tage später.
v = _import(admin, konto["id"], KOPF + "06.05.2026;-12,50;SV GROUP SCHWEIZ AG;Kartenzahlung\n")
zeile = v["rows"][0]
assert zeile["is_duplicate"] is True
assert zeile["duplicate_reason"] == "inbox"
assert zeile["include"] is False
admin.post("/api/imports/commit", json={
"account_id": konto["id"], "filename": "mai.csv",
"rows": [r for r in v["rows"] if r["include"]]})
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 1250
def test_abweichender_betrag_gilt_nicht_als_dieselbe_zahlung(admin):
"""Bei Geld ist Toleranz gefährlich: nur der exakte Betrag zählt."""
client = _fremd()
konto = _konto(admin)
marke = _marke(admin, konto["id"])
client.post("/api/quick/booking",
json={"amount": "12.50", "payee": "Kantine", "date": "2026-06-04"},
headers={"X-Certain-Token": marke["token"]})
v = _import(admin, konto["id"], KOPF + "05.06.2026;-12,60;SV GROUP;Kartenzahlung\n")
assert v["rows"][0]["is_duplicate"] is False
def test_zu_weit_entferntes_datum_gilt_nicht(admin):
client = _fremd()
konto = _konto(admin)
marke = _marke(admin, konto["id"])
client.post("/api/quick/booking",
json={"amount": "31.00", "payee": "Baumarkt", "date": "2026-07-01"},
headers={"X-Certain-Token": marke["token"]})
v = _import(admin, konto["id"], KOPF + "20.07.2026;-31,00;OBI;Einkauf\n")
assert v["rows"][0]["is_duplicate"] is False
def test_zwei_gleiche_zahlungen_verbrauchen_je_einen_eintrag(admin):
"""Zweimal derselbe Kaffee am selben Tag: beide Seiten müssen aufgehen."""
client = _fremd()
konto = _konto(admin)
marke = _marke(admin, konto["id"])
kopf = {"X-Certain-Token": marke["token"]}
for lauf in ("a", "b"):
client.post("/api/quick/booking",
json={"amount": "4.20", "payee": "Kaffee", "date": "2026-09-02",
"external_id": f"kaffee-{lauf}"}, headers=kopf)
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 840
v = _import(admin, konto["id"], KOPF
+ "02.09.2026;-4,20;CAFE BAR;Karte\n"
+ "02.09.2026;-4,20;CAFE BAR;Karte\n"
+ "02.09.2026;-4,20;CAFE BAR;Karte\n")
gruende = [r["duplicate_reason"] for r in v["rows"]]
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