Compare commits

..
3 Commits
Author SHA1 Message Date
GuidoandClaude Opus 5 3dff435156 docs: Uebergabe haelt B und C als erledigt fest und berichtigt den Testbefehl
Backend-Ergaenzungen B und C sind gebaut; Stufe 2 hat damit alles, was sie
braucht. Offen bleibt D, die zwei Kopier-Luecken im Budget.

Dazu berichtigt: Der Testbefehl zeigte auf einen Windows-Pfad, und die zwei
Tests, die als "vorbekannt rot" galten, laufen auf diesem Rechner durch - das
war ein Umgebungsproblem der alten Maschine. Erwartung ist jetzt: 438 Tests,
alle gruen. Wer einen roten sieht, hat ihn verursacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:10:21 +02:00
GuidoandClaude Opus 5 82edc29dfe fix: Die Budget-Vorlage nennt wieder Betriebsart und Zuschlag
GET /api/budget/template gab je Zeile nur planned_cents heraus, obwohl das
Modell modus und zuschlag_cents fuehrt und PUT sie zurueckgibt. Wer die
Vorlage so liest, haelt eine berechnete Zeile fuer eine feste - und schreibt
sie beim naechsten Speichern auch als feste zurueck.

Die Weboberflaeche traf es nicht, weil sie die Betriebsart aus der
Monatsansicht nimmt. Die iOS-App bietet beide Richtungen an und haette den
Fehler sichtbar gemacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:10:21 +02:00
GuidoandClaude Opus 5 1649c84f6a feat: Die Buchungsliste laesst sich im Server durchsuchen und filtern
Drei neue Abfrageparameter an GET /api/transactions: q sucht in Empfaenger,
Verwendungszweck und Notiz, tag_id filtert auf ein Etikett, subscription_id
auf einen Vertrag. Bisher gab es das nur im Browser - die Weboberflaeche laedt
alle Buchungen und siebt sie dort. Am Telefon ist das der Unterschied
zwischen "laedt" und "benutzbar"; das Limit steht nicht umsonst auf 5000.

Zwei Dinge, die die Tests festhalten: Prozent- und Unterstrich-Zeichen im
Suchtext sind Zeichen, keine Platzhalter - sonst faende die Eingabe "50%"
alles. Und ein fremder tag_id liefert nichts statt alles, weil der Filter auf
TransactionTag.owner_id anschlaegt.

Der Tag-Filter nimmt eine Unterabfrage wie der uncategorized-Zweig daneben,
keinen Join: Ein Join gaebe jede Buchung so oft zurueck, wie sie Tags traegt.
Die lokale Abfragevariable hiess q und heisst jetzt abfrage, damit der
Parameter den Namen bekommen konnte.

Bekannte Grenze: SQLite gleicht bei LIKE nur ASCII gross/klein ab, "Mueller"
mit Umlaut findet also nicht jede Schreibweise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:10:21 +02:00
5 changed files with 254 additions and 38 deletions
+13 -1
View File
@@ -46,7 +46,19 @@ def get_template(db: Session = Depends(get_db), user: User = Depends(get_current
.all()
)
return {
"lines": [{"category_id": ln.category_id, "planned_cents": ln.planned_cents} for ln in lines],
# Betriebsart und Zuschlag gehören dazu: PUT gibt sie zurück und die
# Monatsansicht liefert sie ohnehin. Ohne sie zeigt ein Client eine
# berechnete Zeile als feste an und schreibt sie beim nächsten
# Speichern auch als feste zurück.
"lines": [
{
"category_id": ln.category_id,
"planned_cents": ln.planned_cents,
"modus": ln.modus,
"zuschlag_cents": ln.zuschlag_cents,
}
for ln in lines
],
"income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income],
}
+40 -11
View File
@@ -9,7 +9,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import select, union
from sqlalchemy import or_, select, union
from sqlalchemy.orm import Session
from ..database import get_db
@@ -194,18 +194,24 @@ def list_transactions(
type: TxnType | None = None,
date_from: date | None = None,
date_to: date | None = None,
# Suche und die zwei Filter, die es bisher nur im Browser gab. Die
# Weboberfläche lädt alle Buchungen und siebt sie dort; ein Telefon soll
# dafür nicht 5000 Zeilen über die Leitung ziehen müssen.
q: str | None = None,
tag_id: int | None = None,
subscription_id: int | None = None,
# Großzügiges Limit: die Buchungsliste (DataTable) filtert/sortiert im Browser,
# deshalb sollen standardmäßig ALLE Buchungen geladen werden, nicht nur 200.
limit: int = Query(default=5000, le=50000),
offset: int = 0,
):
q = db.query(Transaction).filter(Transaction.owner_id == user.id)
abfrage = db.query(Transaction).filter(Transaction.owner_id == user.id)
if account_id is not None:
q = q.filter(
abfrage = abfrage.filter(
(Transaction.account_id == account_id) | (Transaction.to_account_id == account_id)
)
if category_id is not None:
q = q.filter(Transaction.category_id == category_id)
abfrage = abfrage.filter(Transaction.category_id == category_id)
if uncategorized:
# Verknüpfte Buchungen sind erledigt: Sie gehören zu einem Übertrag und
# zählen in keiner Auswertung. Sie hier nach einer Kategorie zu fragen,
@@ -214,16 +220,39 @@ def list_transactions(
select(TransferLink.from_txn_id).where(TransferLink.owner_id == user.id),
select(TransferLink.to_txn_id).where(TransferLink.owner_id == user.id),
)
q = q.filter(Transaction.category_id.is_(None),
Transaction.type != TxnType.transfer,
Transaction.id.notin_(verknuepft))
abfrage = abfrage.filter(Transaction.category_id.is_(None),
Transaction.type != TxnType.transfer,
Transaction.id.notin_(verknuepft))
if type is not None:
q = q.filter(Transaction.type == type)
abfrage = abfrage.filter(Transaction.type == type)
if date_from is not None:
q = q.filter(Transaction.date >= date_from)
abfrage = abfrage.filter(Transaction.date >= date_from)
if date_to is not None:
q = q.filter(Transaction.date <= date_to)
rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
abfrage = abfrage.filter(Transaction.date <= date_to)
if q:
# LIKE statt Volltextindex: Ein Haushalt bleibt im Zehntausenderbereich,
# da kostet die Suche nichts Spürbares. Was SQLite dabei nicht kann:
# Umlaute gross/klein gleichsetzen – „Müller" findet „müller" nicht.
# Die Prozent- und Unterstrich-Zeichen des Suchtexts werden entwertet,
# sonst wäre jede Eingabe mit „%" ein Platzhalter statt eines Zeichens.
muster = "%" + q.translate(
str.maketrans({"\\": "\\\\", "%": "\\%", "_": "\\_"})
) + "%"
abfrage = abfrage.filter(or_(
Transaction.payee.ilike(muster, escape="\\"),
Transaction.description.ilike(muster, escape="\\"),
Transaction.notes.ilike(muster, escape="\\"),
))
if tag_id is not None:
# Unterabfrage statt Join, wie im uncategorized-Zweig: Ein Join würde
# jede Buchung so oft liefern, wie sie Tags trägt.
mit_tag = select(TransactionTag.transaction_id).where(
TransactionTag.owner_id == user.id, TransactionTag.tag_id == tag_id
)
abfrage = abfrage.filter(Transaction.id.in_(mit_tag))
if subscription_id is not None:
abfrage = abfrage.filter(Transaction.subscription_id == subscription_id)
rows = abfrage.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
base = base_currency(db, user)
ccy = _currency_map(db, user)
woher = _herkunft(db, user, rows)
+126
View File
@@ -0,0 +1,126 @@
"""Suche und Filter auf der Buchungsliste: ``q``, ``tag_id``, ``subscription_id``.
Bisher siebte die Weboberflaeche im Browser und lud dafuer alles, was es gab -
das Limit steht nicht umsonst auf 5000. Am Telefon ist das der Unterschied
zwischen "laedt" und "benutzbar", deshalb sitzen die drei Filter jetzt im
Server.
Die Datenbank lebt ueber den ganzen Testlauf. Texte deshalb eindeutig
wuerfeln, sonst findet die Suche die Daten anderer Tests mit.
"""
import secrets
from conftest import login_ready, new_user
def _konto(admin):
return admin.post("/api/accounts", json={
"name": f"Konto {secrets.token_hex(3)}", "type": "girokonto",
"currency": "EUR", "opening_balance_cents": 0}).json()
def _buchung(admin, konto, **felder):
r = admin.post("/api/transactions", json={
"type": "expense", "date": "2026-09-15", "account_id": konto["id"],
"amount_cents": 100 + secrets.randbelow(9000), **felder})
assert r.status_code == 201, r.text
return r.json()
def _suche(admin, **filter_):
r = admin.get("/api/transactions", params=filter_)
assert r.status_code == 200, r.text
return [t["id"] for t in r.json()]
def test_suche_findet_empfaenger_verwendungszweck_und_notiz(admin):
konto = _konto(admin)
wort = f"Zebra{secrets.token_hex(4)}"
ueber_empfaenger = _buchung(admin, konto, payee=f"Laden {wort}")
ueber_zweck = _buchung(admin, konto, payee="Egal", description=f"Rechnung {wort}")
ueber_notiz = _buchung(admin, konto, payee="Egal", notes=f"wegen {wort}")
daneben = _buchung(admin, konto, payee="Etwas voellig anderes")
gefunden = _suche(admin, q=wort)
assert set(gefunden) == {ueber_empfaenger["id"], ueber_zweck["id"], ueber_notiz["id"]}
assert daneben["id"] not in gefunden
def test_suche_achtet_nicht_auf_gross_und_kleinschreibung(admin):
konto = _konto(admin)
wort = f"Kiosk{secrets.token_hex(4)}"
txn = _buchung(admin, konto, payee=wort.upper())
assert txn["id"] in _suche(admin, q=wort.lower())
def test_prozentzeichen_im_suchtext_bleibt_ein_zeichen(admin):
"""Sonst waere jede Eingabe mit % ein Platzhalter und faende alles."""
konto = _konto(admin)
marke = secrets.token_hex(4)
mit_prozent = _buchung(admin, konto, payee=f"Rabatt%{marke}")
ohne = _buchung(admin, konto, payee=f"Rabatt {marke}")
gefunden = _suche(admin, q=f"Rabatt%{marke}")
assert gefunden == [mit_prozent["id"]]
assert ohne["id"] not in gefunden
def test_tag_filter_zeigt_nur_buchungen_mit_diesem_tag(admin):
konto = _konto(admin)
tag = admin.post("/api/tags", json={
"name": f"Tag {secrets.token_hex(4)}", "color": "#3f51b5"}).json()
mit = _buchung(admin, konto, payee="Mit Etikett")
ohne = _buchung(admin, konto, payee="Ohne Etikett")
admin.patch(f"/api/transactions/{mit['id']}", json={"tag_ids": [tag["id"]]})
gefunden = _suche(admin, tag_id=tag["id"])
assert gefunden == [mit["id"]]
assert ohne["id"] not in gefunden
def test_tag_eines_fremden_nutzers_zeigt_keine_buchungen(admin):
"""Mandanten-Isolation: Eine geratene fremde ID darf nichts aufschliessen."""
konto = _konto(admin)
meine = _buchung(admin, konto, payee="Meine Buchung")
name, passwort = new_user(admin)
fremd, _ = login_ready(name, passwort)
fremder_tag = fremd.post("/api/tags", json={
"name": f"Fremd {secrets.token_hex(4)}", "color": "#eb6834"}).json()
gefunden = _suche(admin, tag_id=fremder_tag["id"])
assert gefunden == []
assert meine["id"] not in gefunden
def test_abo_filter_zeigt_nur_buchungen_dieses_vertrags(admin):
konto = _konto(admin)
abo = admin.post("/api/subscriptions", json={
"name": f"Dienst {secrets.token_hex(4)}", "amount_cents": 999,
"currency": "EUR", "interval_months": 1}).json()
zugeordnet = _buchung(admin, konto, payee="Monatsbeitrag")
frei = _buchung(admin, konto, payee="Einmalkauf")
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{zugeordnet['id']}")
assert r.status_code == 200, r.text
gefunden = _suche(admin, subscription_id=abo["id"])
assert gefunden == [zugeordnet["id"]]
assert frei["id"] not in gefunden
def test_suche_und_tag_filter_greifen_zusammen(admin):
konto = _konto(admin)
wort = f"Baumarkt{secrets.token_hex(4)}"
tag = admin.post("/api/tags", json={
"name": f"Tag {secrets.token_hex(4)}", "color": "#2a78d6"}).json()
beides = _buchung(admin, konto, payee=f"{wort} Farbe")
nur_text = _buchung(admin, konto, payee=f"{wort} Schrauben")
nur_tag = _buchung(admin, konto, payee="Etwas anderes")
for txn in (beides, nur_tag):
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
gefunden = _suche(admin, q=wort, tag_id=tag["id"])
assert gefunden == [beides["id"]]
assert nur_text["id"] not in gefunden and nur_tag["id"] not in gefunden
+30
View File
@@ -369,3 +369,33 @@ def test_aufstellung_zeigt_eine_verschobene_buchung_im_neuen_monat(admin):
assert not any(r["id"] == tx["id"] for r in auf["rows"])
karte2 = admin.get("/api/dashboard/expense-share?group_by=category").json()
assert auf["total"] == next(r for r in karte2["rows"] if r["name"] == "Lebensmittel")["amount"]
def test_vorlage_nennt_betriebsart_und_zuschlag(admin):
"""Die Vorlage gab bisher nur planned_cents heraus.
Wer sie so liest, haelt eine berechnete Zeile fuer eine feste - und
schreibt sie beim naechsten Speichern auch als feste zurueck. PUT und die
Monatsansicht nennen beide Felder laengst; hier fehlten sie schlicht.
Eine eigene Kategorie statt Lebensmittel: Die Testdatenbank lebt ueber den
ganzen Lauf, und eine Vorlagenzeile auf "berechnet" umzustellen wuerde die
Nachbartests treffen.
"""
import secrets
gruppe = next(g for g in admin.get("/api/categories").json()
if g["kind"] == "expense")
kat = admin.post("/api/categories", json={
"group_id": gruppe["id"], "name": f"Vorlagenart {secrets.token_hex(3)}"}).json()
r = admin.put(f"/api/budget/template/category/{kat['id']}",
params={"planned_cents": 12000, "modus": "berechnet",
"zuschlag_cents": 2500})
assert r.status_code == 200, r.text
zeile = next(l for l in admin.get("/api/budget/template").json()["lines"]
if l["category_id"] == kat["id"])
assert zeile["planned_cents"] == 12000
assert zeile["modus"] == "berechnet"
assert zeile["zuschlag_cents"] == 2500
+45 -26
View File
@@ -146,13 +146,15 @@ Die App muss sie nachbauen, das Backend hilft nicht:
nur die Summe; welche Abos dahinterstehen, ermittelt das Frontend selbst aus
`/subscriptions` nach `category_id`.
### Buchungsliste ohne Suche
### Buchungsliste
`GET /api/transactions` kennt nur `account_id`, `category_id`, `uncategorized`,
`type`, `date_from`, `date_to`, `limit` (Standard **5000**), `offset`. **Keine**
Volltextsuche, kein Tag-Filter, keine wählbare Sortierung. Die Weboberfläche
lädt alles und filtert im Browser. Auf dem Telefon ist das spürbar — daher
Backend-Ergänzung B unten.
`GET /api/transactions` kennt `account_id`, `category_id`, `uncategorized`,
`type`, `date_from`, `date_to`, `limit` (Standard **5000**), `offset` und seit
Ergänzung B auch `q`, `tag_id`, `subscription_id`. **Keine** wählbare
Sortierung — immer Datum absteigend, dann ID absteigend.
Die Weboberfläche lädt weiter alles und filtert im Browser; sie wurde nicht
umgestellt, weil sie so funktioniert. Die App soll die Filter benutzen.
### Was nicht in die App kommt
@@ -171,7 +173,7 @@ Backend-Ergänzung B unten.
## 4. Backend-Ergänzungen
Vier geplant. **A ist fertig**, B bis D stehen aus. Jede einzeln dem Nutzer
Vier geplant. **A, B und C sind fertig**, D steht aus. Jede einzeln dem Nutzer
vorlegen, bevor du sie schreibst.
### A — App-Token ✅ (Commit `8a7545b`)
@@ -192,21 +194,36 @@ Zwei Dinge, die die Tests festhalten (`backend/tests/test_app_marke.py`,
`POST /api/inbox/tokens`). Sonst stellte ein Dieb sich Ersatzschlüssel aus
und das Widerrufen liefe ins Leere.
### B — Suche und Tag-Filter auf `/transactions` (offen)
### B — Suche und Tag-Filter auf `/transactions` ✅
Drei Query-Parameter: `q` (Volltext über `payee`, `description`, `notes`),
`tag_id`, `subscription_id`. Muster für den Tag-Filter steht schon da: der
`uncategorized`-Zweig in `backend/app/routers/transactions.py` arbeitet mit
genau so einer Unterabfrage.
`tag_id`, `subscription_id`. Der Tag-Filter arbeitet mit einer Unterabfrage
wie der `uncategorized`-Zweig daneben, nicht mit einem Join — sonst käme eine
Buchung so oft zurück, wie sie Tags trägt.
Nötig für Stufe 2 — ohne das lädt das Telefon für jede Ansicht bis zu 5000
Zeilen.
Zwei Dinge, die die Tests festhalten (`backend/tests/test_buchungssuche.py`,
7 Stück):
- `%` und `_` im Suchtext sind **Zeichen, keine Platzhalter**. Sonst fände
die Eingabe „50%" alles.
- Ein fremder `tag_id` liefert **nichts**, nicht etwa alles. Der Filter
schlägt auf `TransactionTag.owner_id` an.
### C — `GET /budget/template` liefert `modus` und `zuschlag_cents` (offen)
Grenze: SQLite vergleicht bei LIKE nur ASCII ohne Rücksicht auf Gross- und
Kleinschreibung. „Müller" findet „müller" nicht. Reicht, bis jemand es meldet.
Beide fehlen heute in der Antwort (`backend/app/routers/budgets.py`), obwohl
das Modell sie führt und `PUT` sie zurückgibt. Ohne sie kann die App die
Vorlage weder richtig anzeigen noch bearbeiten. Nötig für Stufe 3.
Der lokale Name `q` im Router hiess vorher die Abfrage selbst; die heisst
jetzt `abfrage`, damit der Query-Parameter `q` heissen kann.
### C — `GET /budget/template` liefert `modus` und `zuschlag_cents` ✅
Beide fehlten in der Antwort (`backend/app/routers/budgets.py`), obwohl das
Modell sie führt und `PUT` sie zurückgibt. Jetzt stehen sie in jeder Zeile.
Test: `test_vorlage_nennt_betriebsart_und_zuschlag` in `test_budget.py`.
Das war der stillere der beiden Fehler: Ein Client, der die Vorlage so liest,
zeigt eine berechnete Zeile als feste **und schreibt sie beim nächsten
Speichern auch als feste zurück**. Die Weboberfläche traf es nicht, weil sie
die Betriebsart aus der Monatsansicht nimmt.
### D — Zwei Kopier-Lücken im Budget (offen)
@@ -276,7 +293,7 @@ nicht gegen einen laufenden Server. Siehe „Klartext-HTTP" unten.
**Stufe 2 — Lesen.** Dashboard (feste Ansicht: Kontostände, Budget-Fortschritt,
letzte Buchungen, Einnahmen/Ausgaben), Budget-Monatsansicht, Buchungsliste,
Abo-Liste. Dazu Backend-Ergänzungen **B** und **C**.
Abo-Liste. Die Backend-Ergänzungen **B** und **C**, die sie braucht, sind da.
**Stufe 3 — Schreiben.** Buchungen anlegen und ändern, Budget bearbeiten
(Monat und Vorlage, beide Betriebsarten `fest`/`berechnet`), Abos verwalten
@@ -359,14 +376,16 @@ Leicht zu übersehen, das Backend macht es an mehreren Stellen:
nicht zeigen kann. Das ganze Projekt ist so geschrieben; halte es durch.
- **Neue Swift-Datei?** Danach `cd ios && xcodegen generate`. Die Ordner stehen
einzeln in `project.yml`; ein neuer Ordner will dort eine Zeile.
- **Tests**: `cd backend && ./.venv/Scripts/python.exe -m pytest tests/ -q`
(Python 3.12 im venv; das System-Python kann nicht bauen). Dauert rund zwei
Minuten — im Hintergrund laufen lassen.
- **Vorbekannt rot**, nicht von dir verursacht:
`test_mail_import.py::test_von_hand_weitergeleitete_mail_wird_am_betreff_erkannt`
und `test_migrations.py::test_batch_migration_keeps_dependent_rows` (der
scheitert an einem Umgebungsproblem, `WinError 10106` in einem Subprozess).
Erwartung nach jedem Lauf: genau diese zwei, sonst nichts.
- **Tests**: `cd backend && .venv/bin/python -m pytest tests/ -q` (auf Windows
`./.venv/Scripts/python.exe`). Python 3.12 im venv; das System-Python kann
nicht bauen. Dauert hier rund 45 Sekunden.
- **Erwartung: alles grün**, 438 Tests. Die zwei Ausfälle, die hier früher als
„vorbekannt rot" standen, waren ein Windows-Problem der alten Maschine
(`WinError 10106` im Subprozess) und laufen auf diesem Rechner durch. Wer
einen roten Test sieht, hat ihn verursacht.
- **Fehlt dem venv ein Modul** (`ModuleNotFoundError`), einmal
`.venv/bin/python -m pip install -r requirements.txt` — es war hier nicht
auf dem Stand von `requirements.txt`.
- **Heredocs in Bash scheitern** an den deutschen Anführungszeichen („ ").
Für Dateien mit solchen Zeichen das Write-Werkzeug nehmen, nicht `cat <<EOF`.