fix: Neue Tabellen ins Backup, toter Transfer-Code raus

Sieben nutzereigene Tabellen aus den Migrationen 0015-0020 standen in keiner
Backup-Liste. Am schwersten wiegt inbox_items: Der Eingang haelt fest, welche
Zahlungen von aussen schon erfasst wurden. Fehlt er nach dem Wiedereinspielen,
erkennt der naechste Kontoauszug sie nicht wieder und bucht sie ein zweites
Mal – genau der Fehler, den die Tabelle verhindern soll.

Aufgenommen: inbox_items, transfer_links, link_ignored, api_tokens.
Bewusst nicht: mail_sources/mail_senders (tragen das verschluesselte
IMAP-Passwort; ein Backup ist eine Datei, die man herumreicht) und
pending_uploads (fluechtige Rohdateien). Beides steht begruendet im Code.

Ein Test laeuft jetzt ueber Base.registry und fordert fuer jedes Modell mit
owner_id eine bewusste Entscheidung – sichern oder begruendet ausnehmen.
Genau diese Pruefung fehlte, weshalb die sieben unbemerkt durchrutschten.

Dazu der tote Code aus dem Transfer-Ausbau: is_counterpart und braucht_ziel
waren feste False, _apply_counterpart_amount damit unerreichbar. Die Tests
sichern dieselbe Garantie jetzt ueber das tatsaechliche Verhalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-26 21:16:33 +02:00
co-authored by Claude Opus 5
parent c9a598a056
commit 3ee18c4d21
5 changed files with 80 additions and 47 deletions
+35
View File
@@ -35,6 +35,7 @@ from ..branding import VERSION
from ..models import (
Account,
AccountIdentifier,
ApiToken,
AppSetting,
BudgetPeriod,
BudgetPeriodLine,
@@ -46,9 +47,12 @@ from ..models import (
ExchangeRate,
ImportBatch,
ImportTemplate,
InboxItem,
LinkIgnored,
PlannedIncomeLine,
RecoveryCode,
Transaction,
TransferLink,
User,
UserSetting,
)
@@ -113,8 +117,39 @@ USER_TABLES: list[TableSpec] = [
{"category_id": "categories"}, label="Schlagwortregeln"),
TableSpec("user_settings", UserSetting, "user", has_id=False,
label="Persönliche Einstellungen"),
# Der Eingang MUSS mitgesichert werden: Er hält fest, welche Zahlungen
# bereits von aussen erfasst wurden. Fehlt er nach dem Wiedereinspielen,
# erkennt der nächste Kontoauszug sie nicht wieder und bucht sie ein
# zweites Mal – genau der Fehler, den diese Tabelle verhindern soll.
TableSpec("inbox_items", InboxItem, "owner", {
"account_id": "accounts",
"category_id": "categories",
"transaction_id": "transactions",
}, label="Eingang"),
TableSpec("transfer_links", TransferLink, "owner",
{"from_txn_id": "transactions", "to_txn_id": "transactions"},
label="Übertrags-Verknüpfungen"),
TableSpec("link_ignored", LinkIgnored, "owner",
{"a_txn_id": "transactions", "b_txn_id": "transactions"},
label="Abgelehnte Übertrags-Vorschläge"),
# Ohne die Geräteschlüssel hört der Kurzbefehl auf dem Telefon nach einem
# Wiedereinspielen stillschweigend auf zu funktionieren. Gesichert wird nur
# der Hash – der Klartext existiert nirgends.
TableSpec("api_tokens", ApiToken, "owner",
{"default_account_id": "accounts"}, label="Geräteschlüssel"),
]
# Bewusst NICHT gesichert:
#
# ``mail_sources``/``mail_senders`` – die Quelle trägt das verschlüsselte
# IMAP-Passwort. Ein Backup ist eine Datei, die der Nutzer herumreicht; dort
# gehört keine Zugangsdatei hinein, auch keine verschlüsselte. Nach einem
# Wiedereinspielen richtet man das Postfach neu ein, das dauert zwei Minuten.
#
# ``pending_uploads`` – dort liegen rohe Dateien, die noch niemand geprüft hat.
# Sie sind flüchtig, blähen ein JSON-Backup um Megabytes auf und lassen sich
# jederzeit erneut vom Telefon schicken.
# Nur im systemweiten Backup, je Benutzer.
SYSTEM_USER_TABLES: list[TableSpec] = [
TableSpec("recovery_codes", RecoveryCode, "user", label="Wiederherstellungscodes"),
+2 -41
View File
@@ -766,9 +766,6 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
is_dup = True
dup_grund = "inbox"
is_counterpart = False
braucht_ziel = False
out.append({
"index": i,
"date": d.isoformat() if d else None,
@@ -791,11 +788,9 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
"import_hash": h,
"is_duplicate": is_dup,
"duplicate_reason": dup_grund,
"is_counterpart": is_counterpart,
# Damit commit_import die geschätzte Gegenseite auf den echten Wert setzt.
"counterpart_txn_id": None,
"include": valid and not is_dup and not is_counterpart and not braucht_ziel,
"needs_target": braucht_ziel,
"include": valid and not is_dup,
})
return out
@@ -822,7 +817,7 @@ def reconcile(rows: list[dict]) -> dict:
einer früher gebuchten Buchung. Eine Zeile, die auf ein Gegenkonto wartet,
zählt dagegen NICHT – sie ist unterwegs verloren, und genau das soll der
Abgleich melden."""
if not r.get("valid") or r.get("needs_target"):
if not r.get("valid"):
return 0
return r.get("amount_cents") or 0
@@ -991,34 +986,6 @@ def _track_and_learn(db: Session, user: User, row: dict) -> None:
_learn_rule(db, user, row)
def _apply_counterpart_amount(db: Session, user: User, account: Account, row: dict) -> bool:
"""Trägt den tatsächlichen Betrag einer Umtausch-Gegenbuchung in den bereits
gebuchten Transfer ein. Diese Zeile stammt aus dem Auszug der anderen Seite und
nennt exakt, was dort angekommen bzw. abgegangen ist."""
txn_id = row.get("counterpart_txn_id")
amount = row.get("amount_cents")
if not txn_id or not amount or not row.get("is_counterpart"):
return False
txn = db.get(Transaction, txn_id)
if txn is None or txn.owner_id != user.id or txn.type != TxnType.transfer:
return False
absamt = abs(amount)
if txn.to_account_id == account.id: # hier ist das Geld angekommen
if txn.to_amount_cents == absamt:
return False
txn.to_amount_cents = absamt
elif txn.account_id == account.id: # hier ist es abgegangen
if txn.amount_cents == absamt:
return False
txn.amount_cents = absamt
txn.amount_eur_cents = currency.compute_amount_eur_cents(
db, absamt, account.currency, txn.date
)
else:
return False
return True
def commit_import(db: Session, user: User, account: Account, template_id: int | None, filename: str,
rows: list[dict], preset: str | None = None) -> ImportBatch:
accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
@@ -1031,14 +998,8 @@ def commit_import(db: Session, user: User, account: Account, template_id: int |
db.flush()
imported = 0
korrigiert = 0
for row in rows:
if not row.get("include", True):
# Gegenbuchung eines Umtauschs: nicht buchen, aber den echten Betrag
# dieser Seite in den bestehenden Transfer eintragen. Sonst bliebe dort
# die EZB-Schätzung stehen und der Saldo wiche um die Kursdifferenz ab.
if _apply_counterpart_amount(db, user, account, row):
korrigiert += 1
continue
txn = _make_transaction(db, user, account, accounts, row, batch.id)
if txn is None: