fix: Ein Uebertrag im Eingang fragt nicht mehr nach der Kategorie
Die Zeile im Eingang zeigte die Kategorie-Auswahl bedingungslos – auch bei einem Kontouebertrag, der gar keine bekommt. Die Oberflaeche konnte es auch nicht wissen: InboxItemOut hatte kein Feld, an dem sich ein Uebertrag erkennen laesst. Jetzt liefert der Eintrag transfer_account_id mit – das jeweils ANDERE Konto, aus seiner Sicht. Steht es, zeigt die Zeile '↔ Kontoname' statt der Auswahl. Ermittelt wird es mit einer Sammelabfrage, nicht je Zeile: Die Liste geht bis 200 Eintraege. Dazu die Luecke dahinter: set_category schrieb die Kategorie auch auf die Buchung und konnte sie so auf einem TxnType.transfer ablegen – genau das, was das Buchungsformular beim Anlegen verhindert. Bei einem Uebertrag lehnt der Endpunkt jetzt ab; bei allem anderen bleibt es, wie es war. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,19 +72,55 @@ def delete_token(token_id: int, db: Session = Depends(get_db), user: User = Depe
|
|||||||
# --- Eingang -----------------------------------------------------------------
|
# --- Eingang -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _gegenkonten(db: Session, user: User, eintraege: list[InboxItem]) -> dict[int, int]:
|
||||||
|
"""Je Eintrag das Gegenkonto, falls seine Buchung ein Übertrag ist.
|
||||||
|
|
||||||
|
Als Sammelabfrage und nicht je Zeile: Die Liste geht bis 200 Einträge.
|
||||||
|
"""
|
||||||
|
txn_ids = {e.transaction_id for e in eintraege if e.transaction_id is not None}
|
||||||
|
if not txn_ids:
|
||||||
|
return {}
|
||||||
|
buchungen = {
|
||||||
|
b.id: b
|
||||||
|
for b in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.id.in_(txn_ids),
|
||||||
|
Transaction.type == TxnType.transfer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
gegen: dict[int, int] = {}
|
||||||
|
for e in eintraege:
|
||||||
|
buchung = buchungen.get(e.transaction_id)
|
||||||
|
if buchung is None:
|
||||||
|
continue
|
||||||
|
# Das jeweils ANDERE Konto, aus Sicht dieses Eintrags.
|
||||||
|
gegen[e.id] = (buchung.to_account_id if buchung.account_id == e.account_id
|
||||||
|
else buchung.account_id)
|
||||||
|
return gegen
|
||||||
|
|
||||||
|
|
||||||
|
def _out(db: Session, user: User, eintraege: list[InboxItem]) -> list[InboxItemOut]:
|
||||||
|
gegen = _gegenkonten(db, user, eintraege)
|
||||||
|
return [
|
||||||
|
InboxItemOut.model_validate(e).model_copy(
|
||||||
|
update={"transfer_account_id": gegen.get(e.id)})
|
||||||
|
for e in eintraege
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[InboxItemOut])
|
@router.get("", response_model=list[InboxItemOut])
|
||||||
def list_inbox(
|
def list_inbox(
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
return (
|
return _out(db, user, (
|
||||||
db.query(InboxItem)
|
db.query(InboxItem)
|
||||||
.filter(InboxItem.owner_id == user.id, InboxItem.status != InboxStatus.discarded)
|
.filter(InboxItem.owner_id == user.id, InboxItem.status != InboxStatus.discarded)
|
||||||
.order_by(InboxItem.date.desc(), InboxItem.id.desc())
|
.order_by(InboxItem.date.desc(), InboxItem.id.desc())
|
||||||
.limit(min(limit, 1000))
|
.limit(min(limit, 1000))
|
||||||
.all()
|
.all()
|
||||||
)
|
))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{item_id}/book", response_model=InboxItemOut)
|
@router.post("/{item_id}/book", response_model=InboxItemOut)
|
||||||
@@ -99,7 +135,7 @@ def book_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(
|
|||||||
db.commit()
|
db.commit()
|
||||||
eingang.suche_uebertraege(db, user)
|
eingang.suche_uebertraege(db, user)
|
||||||
db.refresh(eintrag)
|
db.refresh(eintrag)
|
||||||
return eintrag
|
return _out(db, user, [eintrag])[0]
|
||||||
|
|
||||||
|
|
||||||
class InboxTransferIn(BaseModel):
|
class InboxTransferIn(BaseModel):
|
||||||
@@ -142,7 +178,7 @@ def book_as_transfer(item_id: int, payload: InboxTransferIn,
|
|||||||
detail="Lässt sich nicht buchen")
|
detail="Lässt sich nicht buchen")
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(eintrag)
|
db.refresh(eintrag)
|
||||||
return eintrag
|
return _out(db, user, [eintrag])[0]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/book-all")
|
@router.post("/book-all")
|
||||||
@@ -194,11 +230,19 @@ def set_category(
|
|||||||
|
|
||||||
eintrag = get_owned(db, InboxItem, item_id, user)
|
eintrag = get_owned(db, InboxItem, item_id, user)
|
||||||
kategorie = get_owned(db, Category, category_id, user)
|
kategorie = get_owned(db, Category, category_id, user)
|
||||||
|
txn = (db.get(Transaction, eintrag.transaction_id)
|
||||||
|
if eintrag.transaction_id is not None else None)
|
||||||
|
if txn is not None and txn.owner_id == user.id:
|
||||||
|
# Ein Übertrag ist keine Ausgabe. Ohne diese Sperre landete über den
|
||||||
|
# Eingang eine Kategorie auf einer Übertrags-Buchung – genau das, was
|
||||||
|
# das Buchungsformular beim Anlegen verhindert.
|
||||||
|
if txn.type == TxnType.transfer:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Ein Kontoübertrag bekommt keine Kategorie.",
|
||||||
|
)
|
||||||
|
txn.category_id = kategorie.id
|
||||||
eintrag.category_id = kategorie.id
|
eintrag.category_id = kategorie.id
|
||||||
if eintrag.transaction_id is not None:
|
|
||||||
txn = db.get(Transaction, eintrag.transaction_id)
|
|
||||||
if txn is not None and txn.owner_id == user.id:
|
|
||||||
txn.category_id = kategorie.id
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(eintrag)
|
db.refresh(eintrag)
|
||||||
return eintrag
|
return _out(db, user, [eintrag])[0]
|
||||||
|
|||||||
@@ -533,6 +533,10 @@ class InboxItemOut(BaseModel):
|
|||||||
note: str | None
|
note: str | None
|
||||||
category_id: int | None
|
category_id: int | None
|
||||||
transaction_id: int | None
|
transaction_id: int | None
|
||||||
|
# Das jeweils ANDERE Konto, wenn die Buchung ein Kontoübertrag ist. Ohne
|
||||||
|
# das kann die Oberfläche einen Übertrag nicht von einer Ausgabe
|
||||||
|
# unterscheiden und fragt weiter nach einer Kategorie.
|
||||||
|
transfer_account_id: int | None = None
|
||||||
created_at: datetime | None
|
created_at: datetime | None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ def _konto(admin, **kw):
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _kategorie(admin):
|
||||||
|
"""Irgendeine Ausgabe-Kategorie aus den Vorgaben."""
|
||||||
|
for gruppe in admin.get("/api/categories").json():
|
||||||
|
if gruppe["kind"] == "expense" and gruppe["categories"]:
|
||||||
|
return gruppe["categories"][0]["id"]
|
||||||
|
raise AssertionError("keine Ausgabe-Kategorie vorhanden")
|
||||||
|
|
||||||
|
|
||||||
def _eingang(admin, konto, betrag_cents, datum="2026-08-20", payee="Umbuchung"):
|
def _eingang(admin, konto, betrag_cents, datum="2026-08-20", payee="Umbuchung"):
|
||||||
"""Ein OFFENER Eintrag – so wie ihn Mail und Bankabruf hinterlassen.
|
"""Ein OFFENER Eintrag – so wie ihn Mail und Bankabruf hinterlassen.
|
||||||
|
|
||||||
@@ -142,3 +150,44 @@ def test_angebundenes_zielkonto_wird_abgefangen(admin):
|
|||||||
json={"to_account_id": ziel["id"], "trotzdem": True})
|
json={"to_account_id": ziel["id"], "trotzdem": True})
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 10000
|
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 10000
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebertrag_nennt_das_gegenkonto_und_nimmt_keine_kategorie(admin):
|
||||||
|
"""Ohne das Gegenkonto kann die Oberfläche einen Übertrag nicht von einer
|
||||||
|
Ausgabe unterscheiden – und fragt weiter nach einer Kategorie."""
|
||||||
|
von = _konto(admin, name=f"Nenn-Von {secrets.token_hex(3)}", currency="CHF")
|
||||||
|
nach = _konto(admin, name=f"Nenn-Nach {secrets.token_hex(3)}", currency="CHF")
|
||||||
|
eintrag = _eingang(admin, von, -8000)
|
||||||
|
|
||||||
|
r = admin.post(f"/api/inbox/{eintrag['id']}/transfer", json={"to_account_id": nach["id"]})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["transfer_account_id"] == nach["id"]
|
||||||
|
|
||||||
|
# Auch in der Liste, und aus Sicht des Eintrags das jeweils ANDERE Konto.
|
||||||
|
aus_liste = [e for e in admin.get("/api/inbox").json() if e["id"] == eintrag["id"]][0]
|
||||||
|
assert aus_liste["transfer_account_id"] == nach["id"]
|
||||||
|
|
||||||
|
r = admin.post(f"/api/inbox/{eintrag['id']}/category/{_kategorie(admin)}")
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert "Kategorie" in r.json()["detail"]
|
||||||
|
|
||||||
|
# Und die Buchung hat weiterhin keine.
|
||||||
|
txn = admin.get(f"/api/transactions/{aus_liste['transaction_id']}").json()
|
||||||
|
assert txn["category_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_normale_buchung_nimmt_weiterhin_eine_kategorie(admin):
|
||||||
|
"""Die Sperre darf nur Überträge treffen."""
|
||||||
|
konto = _konto(admin, name=f"Normal {secrets.token_hex(3)}", currency="CHF")
|
||||||
|
# Eigener Betrag: Die Testdatenbank lebt ueber den ganzen Lauf, und ein
|
||||||
|
# runder Betrag kollidiert mit dem, wonach ein anderer Test sucht.
|
||||||
|
eintrag = _eingang(admin, konto, -(700000 + secrets.randbelow(90000)))
|
||||||
|
admin.post(f"/api/inbox/{eintrag['id']}/book")
|
||||||
|
|
||||||
|
kat = _kategorie(admin)
|
||||||
|
r = admin.post(f"/api/inbox/{eintrag['id']}/category/{kat}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["category_id"] == kat
|
||||||
|
assert r.json()["transfer_account_id"] is None
|
||||||
|
txn = admin.get(f"/api/transactions/{r.json()['transaction_id']}").json()
|
||||||
|
assert txn["category_id"] == kat, "die Kategorie muss auch an der Buchung stehen"
|
||||||
|
|||||||
@@ -213,11 +213,23 @@ export default function Eingang() {
|
|||||||
</div>
|
</div>
|
||||||
<Money cents={Math.abs(item.amount_cents)} currency={item.currency}
|
<Money cents={Math.abs(item.amount_cents)} currency={item.currency}
|
||||||
sign={ausgabe ? "neg" : "pos"} baseCurrency={baseCurrency} />
|
sign={ausgabe ? "neg" : "pos"} baseCurrency={baseCurrency} />
|
||||||
<CategorySelect
|
{/* Ein Übertrag ist keine Ausgabe – er bekommt keine
|
||||||
groups={ausgabe ? expenseGroups : incomeGroups}
|
Kategorie, also auch keine Auswahl dafür. */}
|
||||||
value={item.category_id}
|
{item.transfer_account_id != null ? (
|
||||||
onChange={(id) => zuordnen(item, id)}
|
<div className="uebertrag-marke">
|
||||||
/>
|
<Icon name="arrow-left-right" size={14} />
|
||||||
|
<span className="ellipsis">
|
||||||
|
{accountById[item.transfer_account_id]?.name
|
||||||
|
|| t("transfers.linked")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CategorySelect
|
||||||
|
groups={ausgabe ? expenseGroups : incomeGroups}
|
||||||
|
value={item.category_id}
|
||||||
|
onChange={(id) => zuordnen(item, id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{item.status !== "booked" && item.account_id && (
|
{item.status !== "booked" && item.account_id && (
|
||||||
<>
|
<>
|
||||||
<button className="btn sm primary" onClick={() => buchen(item)}>
|
<button className="btn sm primary" onClick={() => buchen(item)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user