diff --git a/backend/app/models.py b/backend/app/models.py index da5c26a..07ef95b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -678,6 +678,12 @@ class InboxItem(Base): transaction_id: Mapped[int | None] = mapped_column( ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True ) + # Was die Bank ueber die Umrechnung mitteilt. Ihr eigener Kurs schlaegt die + # EZB-Naeherung – und nur mit dem exakten Gegenbetrag laesst sich die + # spaeter gelieferte Gegenseite eines Uebertrags wiedererkennen. + fx_rate: Mapped[float | None] = mapped_column(Numeric(18, 8), nullable=True) + counter_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + counter_currency: Mapped[str | None] = mapped_column(String(3), nullable=True) # Kennung der Quelle (Message-ID der Mail, Kennung des Kurzbefehls): # schützt davor, dieselbe Meldung zweimal zu erfassen. external_id: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index bfc75cb..8aca1f4 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -239,6 +239,39 @@ def _entkoppele_uebertraege(db: Session, acc: Account) -> None: db.flush() +@router.post("/{account_id}/saldo-uebernehmen", response_model=AccountOut) +def adopt_bank_balance(account_id: int, db: Session = Depends(get_db), + user: User = Depends(get_current_user)): + """Den von der Bank gemeldeten Saldo als richtig übernehmen. + + Die Differenz wandert in den ANFANGSBESTAND, nicht in eine Korrektur- + buchung. Sie steht für Bewegungen, die Certain nie gesehen hat – und + genau dafür ist der Anfangsbestand da. Eine erfundene Buchung stünde + dagegen für immer in der Liste und liefe durch jede Auswertung. + + Die Buchungen selbst bleiben unangetastet. + """ + konto = get_owned(db, Account, account_id, user) + verknuepfung = ( + db.query(BankAccountLink) + .filter(BankAccountLink.owner_id == user.id, + BankAccountLink.account_id == konto.id, + BankAccountLink.balance_cents.isnot(None)) + .first() + ) + if verknuepfung is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=("Für dieses Konto liegt kein Saldo der Bank vor. Erst " + "abrufen, dann übernehmen."), + ) + unterschied = verknuepfung.balance_cents - balances.account_balance_cents(db, konto) + konto.opening_balance_cents += unterschied + db.commit() + db.refresh(konto) + return to_out(db, konto, base_currency(db, user), date.today(), _bank_saldi(db, user)) + + @router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_account( account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b162630..a19c93c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -537,6 +537,10 @@ class InboxItemOut(BaseModel): # das kann die Oberfläche einen Übertrag nicht von einer Ausgabe # unterscheiden und fragt weiter nach einer Kategorie. transfer_account_id: int | None = None + # Was die Bank ueber die Umrechnung mitteilt – ihr Kurs schlaegt die EZB. + fx_rate: float | None = None + counter_amount_cents: int | None = None + counter_currency: str | None = None created_at: datetime | None model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index 2287135..8808b16 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -309,6 +309,61 @@ def _betrag_cents(umsatz: dict) -> int | None: return cents +def _betrag_mit_waehrung(wert) -> tuple[int, str] | None: + """Aus ``{"amount": "12.00", "currency": "EUR"}`` Cent und Waehrung.""" + if not isinstance(wert, dict): + return None + cents = csv_import.parse_amount(str(wert.get("amount")), ".") + waehrung = wert.get("currency") + if cents is None or not waehrung: + return None + return abs(cents), str(waehrung).upper()[:3] + + +def _umrechnung(umsatz: dict, waehrung: str | None) -> tuple[float | None, int | None, str | None]: + """Kurs und Gegenbetrag, wie die Bank sie mitteilt. + + Die Feldnamen unterscheiden sich je Anbindung, deshalb werden mehrere + Formen abgeklopft – und es wird still NICHTS gesetzt, wenn keine passt. + Raten waere hier schlimmer als die EZB-Naeherung, denn ein falscher Kurs + sieht aus wie ein richtiger. + """ + wechsel = umsatz.get("currency_exchange") + if isinstance(wechsel, list): + wechsel = next((w for w in wechsel if isinstance(w, dict)), None) + if not isinstance(wechsel, dict): + wechsel = {} + + kurs = None + for feld in ("exchange_rate", "rate", "unit_rate"): + roh = wechsel.get(feld) if wechsel else None + if roh is None: + roh = umsatz.get(feld) + if roh is None: + continue + try: + wert = float(str(roh).replace(",", ".")) + except (TypeError, ValueError): + continue + if wert > 0: + kurs = wert + break + + # Der Betrag in der ANDEREN Waehrung. Nur brauchbar, wenn er sich von der + # Waehrung des Umsatzes unterscheidet – sonst ist es derselbe Betrag. + gegen = None + for quelle in (wechsel, umsatz): + for feld in ("instructed_amount", "original_amount", "source_amount"): + treffer = _betrag_mit_waehrung((quelle or {}).get(feld)) + if treffer and treffer[1] != (waehrung or "").upper(): + gegen = treffer + break + if gegen: + break + + return kurs, (gegen[0] if gegen else None), (gegen[1] if gegen else None) + + def _text(umsatz: dict, ausgabe: bool) -> tuple[str | None, str | None]: """Empfänger und Verwendungszweck. Bei einer Ausgabe zählt der Empfänger, bei einer Einnahme der Absender.""" @@ -530,6 +585,9 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, if zeile.get("is_duplicate"): continue + waehrung = ((umsatz.get("transaction_amount") or {}).get("currency") + or konto.currency) + kurs, gegen_betrag, gegen_waehrung = _umrechnung(umsatz, waehrung) eintrag = InboxItem( owner_id=user.id, source=InboxSource.bank, @@ -537,12 +595,14 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, account_id=konto.id, date=tag, amount_cents=cents, - currency=(umsatz.get("transaction_amount") or {}).get("currency") - or konto.currency, + currency=waehrung, payee=payee, note=zweck, category_id=zeile.get("category_id"), external_id=kennung, + fx_rate=kurs, + counter_amount_cents=gegen_betrag, + counter_currency=gegen_waehrung, ) db.add(eintrag) db.flush() diff --git a/backend/app/services/uebertrag_buchen.py b/backend/app/services/uebertrag_buchen.py index ee57bf7..a355783 100644 --- a/backend/app/services/uebertrag_buchen.py +++ b/backend/app/services/uebertrag_buchen.py @@ -65,16 +65,30 @@ def ist_angebunden(db: Session, user: User, konto: Account) -> bool: def _gegenbetrag(db: Session, konto: Account, ziel: Account, betrag: int, - datum) -> tuple[int | None, bool]: - """Betrag auf der Zielseite und ob er BEKANNT oder geschaetzt ist. + datum, eintrag: InboxItem | None = None, + ) -> tuple[int | None, float | None, bool]: + """Betrag auf der Zielseite, Kurs, und ob der Betrag BEKANNT ist. - Gleiche Waehrung: derselbe Betrag, exakt. Sonst der EZB-Kurs – eine - Naeherung, denn die Bank rechnet mit ihrem eigenen Kurs. + Drei Stufen, absteigend nach Verlaesslichkeit: + + 1. Der Gegenbetrag, den die Bank selbst genannt hat – exakt. + 2. Ihr Kurs. Genauer als die EZB, aber der Betrag bleibt gerechnet. + 3. Der EZB-Kurs. Eine Naeherung; die Bank rechnet mit ihrem eigenen Kurs. + + Nur in Stufe 1 gilt der Betrag als bekannt. Daran haengt, ob bei einem + angebundenen Zielkonto nachgefragt wird: Ein gerechneter Betrag laesst sich + beim naechsten Abruf nicht als dieselbe Bewegung wiedererkennen. """ if ziel.currency == konto.currency: - return betrag, True - umgerechnet = currency.convert(db, betrag, konto.currency, ziel.currency, datum) - return umgerechnet, False + return betrag, None, True + + if (eintrag is not None and eintrag.counter_amount_cents + and (eintrag.counter_currency or "").upper() == ziel.currency.upper()): + return abs(eintrag.counter_amount_cents), None, True + + kurs = float(eintrag.fx_rate) if eintrag is not None and eintrag.fx_rate else None + umgerechnet = currency.convert(db, betrag, konto.currency, ziel.currency, datum, kurs) + return umgerechnet, kurs, False def _pruefe(db: Session, user: User, konto: Account, ziel: Account, @@ -97,7 +111,8 @@ def _pruefe(db: Session, user: User, konto: Account, ziel: Account, def _lege_an(db: Session, user: User, konto: Account, ziel: Account, betrag_signiert: int, ziel_betrag: int | None, datum, - payee: str | None, beschreibung: str | None) -> Transaction: + payee: str | None, beschreibung: str | None, + kurs: float | None = None) -> Transaction: """Die Uebertrags-Buchung, richtig herum. Bei einem Zugang kennen wir den Betrag auf DIESEM Konto exakt – der gehoert @@ -119,6 +134,7 @@ def _lege_an(db: Session, user: User, konto: Account, ziel: Account, to_account_id=nach.id, amount_cents=von_betrag, to_amount_cents=nach_betrag, + fx_rate=kurs, payee=payee, description=beschreibung, ) @@ -185,11 +201,11 @@ def aus_eingang(db: Session, user: User, eintrag: InboxItem, ziel: Account, raise UebertragFehler("Kein Konto zugeordnet") betrag = abs(eintrag.amount_cents) - ziel_betrag, exakt = _gegenbetrag(db, konto, ziel, betrag, eintrag.date) + ziel_betrag, kurs, exakt = _gegenbetrag(db, konto, ziel, betrag, eintrag.date, eintrag) _pruefe(db, user, konto, ziel, exakt, trotzdem) txn = _lege_an(db, user, konto, ziel, eintrag.amount_cents, ziel_betrag, - eintrag.date, eintrag.payee, eintrag.note) + eintrag.date, eintrag.payee, eintrag.note, kurs) eintrag.transaction_id = txn.id eintrag.status = InboxStatus.booked eintrag.category_id = None @@ -212,7 +228,7 @@ def aus_buchung(db: Session, user: User, txn: Transaction, ziel: Account, raise UebertragFehler("Buchung ohne Konto") signiert = txn.amount_cents if txn.type == TxnType.income else -txn.amount_cents - ziel_betrag, exakt = _gegenbetrag(db, konto, ziel, abs(signiert), txn.date) + ziel_betrag, _kurs, exakt = _gegenbetrag(db, konto, ziel, abs(signiert), txn.date) _pruefe(db, user, konto, ziel, exakt, trotzdem) # Die vorhandene Buchung umschreiben statt eine zweite anzulegen: Alles, was diff --git a/backend/migrations/versions/0025_eingang_wechselkurs.py b/backend/migrations/versions/0025_eingang_wechselkurs.py new file mode 100644 index 0000000..f5b9be9 --- /dev/null +++ b/backend/migrations/versions/0025_eingang_wechselkurs.py @@ -0,0 +1,35 @@ +"""Was die Bank ueber die Umrechnung mitteilt. + +Bei einer Fremdwaehrungs-Zahlung nennt die Bank oft ihren echten Kurs und den +Betrag in der Ausgangswaehrung. Certain warf das weg und rechnete mit dem +EZB-Kurs – einer Naeherung, die auf den Rappen nicht stimmt. + +Das faellt genau dort auf, wo es teuer ist: Bucht man einen Uebertrag ueber +zwei Waehrungen, laesst sich die spaeter von der Bank gelieferte Gegenseite mit +einem geschaetzten Betrag nicht als dieselbe wiedererkennen. + +Revision ID: 0025 +Revises: 0024 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0025" +down_revision = "0024" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("inbox_items") as b: + b.add_column(sa.Column("fx_rate", sa.Numeric(18, 8), nullable=True)) + b.add_column(sa.Column("counter_amount_cents", sa.Integer(), nullable=True)) + b.add_column(sa.Column("counter_currency", sa.String(length=3), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("inbox_items") as b: + b.drop_column("counter_currency") + b.drop_column("counter_amount_cents") + b.drop_column("fx_rate") diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 0d73fdc..34aec3d 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -877,3 +877,54 @@ def test_spaeter_gelieferte_gegenseite_wird_nicht_noch_einmal_gebucht(admin, fak assert abruf["geholt"] == 1, "der Umsatz kam an" assert abruf["neu"] == 0, "und wurde als Gegenseite erkannt" assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == vorher + + +def test_kurs_der_bank_wird_uebernommen(admin, fake_bank): + """Die Bank rechnet mit ihrem eigenen Kurs. Ihn wegzuwerfen und mit dem + EZB-Kurs zu naehern, macht den Betrag der Gegenseite unbrauchbar.""" + _app(admin) + uid = f"acc-{secrets.token_hex(4)}" + fake_bank( + konten=[{"uid": uid, "currency": "CHF"}], + umsaetze=[{ + "entry_reference": f"tx-{secrets.token_hex(4)}", + "booking_date": date.today().isoformat(), + "transaction_amount": {"amount": "93.50", "currency": "CHF"}, + "credit_debit_indicator": "DBIT", + "creditor": {"name": "Eigenes Euro-Konto"}, + "currency_exchange": { + "source_currency": "CHF", "target_currency": "EUR", + "exchange_rate": "1.0695", + "instructed_amount": {"amount": "100.00", "currency": "EUR"}, + }, + }], + ) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Kurs-{secrets.token_hex(3)}", + "country": "CH"}).json()["state"] + admin.post(f"/api/bank/callback?state={state}&code=guter-code") + link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0] + chf = _konto(admin, name=f"Kurs CHF {secrets.token_hex(3)}", currency="CHF") + admin.post(f"/api/bank/links/{link['id']}/account/{chf['id']}") + admin.post(f"/api/bank/links/{link['id']}/sync") + + eintrag = [e for e in admin.get("/api/inbox").json() + if e["payee"] == "Eigenes Euro-Konto"][0] + assert eintrag["counter_amount_cents"] == 10000 + assert eintrag["counter_currency"] == "EUR" + assert float(eintrag["fx_rate"]) == 1.0695 + + +def test_ohne_angabe_wird_nichts_geraten(admin, fake_bank): + """Ein falscher Kurs sieht aus wie ein richtiger – lieber gar keiner.""" + verbindung, link, _ = _verbunden(admin, fake_bank, + umsaetze=[dict(UMSATZ, + booking_date=date.today().isoformat())]) + konto = _konto(admin, name=f"Ohne Kurs {secrets.token_hex(3)}", currency="EUR") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + admin.post(f"/api/bank/connections/{verbindung}/sync") + + eintrag = [e for e in admin.get("/api/inbox").json() + if e["payee"] == "SPEEDEE RESTAURATIONS"][0] + assert eintrag["fx_rate"] is None + assert eintrag["counter_amount_cents"] is None diff --git a/backend/tests/test_inbox.py b/backend/tests/test_inbox.py index 6f7d7d1..cb2bca5 100644 --- a/backend/tests/test_inbox.py +++ b/backend/tests/test_inbox.py @@ -302,3 +302,43 @@ def test_nachtraeglicher_uebertrag_dreht_eine_einnahme_um(admin): assert r.json()["to_account_id"] == hier["id"] assert admin.get(f"/api/accounts/{hier['id']}").json()["balance_cents"] == betrag assert admin.get(f"/api/accounts/{dort['id']}").json()["balance_cents"] == -betrag + + +def test_bank_saldo_laesst_sich_uebernehmen(admin): + """Die Differenz gehoert in den Anfangsbestand: Sie steht fuer Bewegungen, + die Certain nie gesehen hat. Eine Korrekturbuchung liefe durch jede + Auswertung und bliebe fuer immer in der Liste.""" + from app.database import SessionLocal + from app.models import BankAccountLink + + konto = _konto(admin, name=f"Saldo {secrets.token_hex(3)}", currency="CHF") + _binde_an(konto) + admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-08-14", "account_id": konto["id"], + "amount_cents": 5000, "payee": "Etwas"}) + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -5000 + + db = SessionLocal() + try: + v = db.query(BankAccountLink).filter( + BankAccountLink.account_id == konto["id"]).first() + v.balance_cents = 120000 # die Bank sagt: 1'200.00 + db.commit() + finally: + db.close() + + r = admin.post(f"/api/accounts/{konto['id']}/saldo-uebernehmen") + assert r.status_code == 200, r.text + assert r.json()["balance_cents"] == 120000 + assert r.json()["opening_balance_cents"] == 125000, "Differenz im Anfangsbestand" + + # Die Buchung selbst ist unangetastet. + offen = admin.get("/api/transactions", params={"account_id": konto["id"]}).json() + assert len(offen) == 1 and offen[0]["amount_cents"] == 5000 + + +def test_uebernehmen_ohne_bank_saldo_wird_erklaert(admin): + konto = _konto(admin, name=f"Ohne Saldo {secrets.token_hex(3)}", currency="CHF") + r = admin.post(f"/api/accounts/{konto['id']}/saldo-uebernehmen") + assert r.status_code == 400 + assert "abrufen" in r.json()["detail"] diff --git a/web/src/api.js b/web/src/api.js index e515a2d..17ed8e7 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -231,6 +231,10 @@ export const api = { // Nur dieses eine Konto abrufen – ein Abruf bucht, und wer ein Konto holt, // will das andere nicht angefasst haben. syncBankLink: (linkId) => request(`/bank/links/${linkId}/sync`, { method: "POST" }), + // Den Saldo der Bank als richtig uebernehmen – die Differenz wandert in + // den Anfangsbestand, die Buchungen bleiben unangetastet. + adoptBankBalance: (accountId) => + request(`/accounts/${accountId}/saldo-uebernehmen`, { method: "POST" }), setBankPoll: (id, minutes) => request(`/bank/connections/${id}/takt?minutes=${minutes}`, { method: "PUT" }), deleteBankConnection: (id) => request(`/bank/connections/${id}`, { method: "DELETE" }), diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 3f1d0cd..32b19c8 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -174,7 +174,10 @@ "moveUp": "Nach oben", "moveDown": "Nach unten", "bankBalanceMatches": "Saldo stimmt mit der Bank überein", - "bankBalanceDiffers": "Bank: {{bank}} · Certain weicht um {{diff}} ab" + "bankBalanceDiffers": "Bank: {{bank}} · Certain weicht um {{diff}} ab", + "adoptBankBalance": "Saldo der Bank übernehmen", + "adoptConfirm": "Der Saldo von {{bank}} wird als richtig übernommen. Die Differenz von {{diff}} wandert in den Anfangsbestand – sie steht für Bewegungen, die Certain nie gesehen hat. Deine Buchungen bleiben unverändert.", + "adopted": "Saldo übernommen." }, "accountTypes": { "girokonto": "Girokonto", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 347b029..c96e580 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -174,7 +174,10 @@ "moveUp": "Move up", "moveDown": "Move down", "bankBalanceMatches": "Balance matches the bank", - "bankBalanceDiffers": "Bank: {{bank}} · Certain differs by {{diff}}" + "bankBalanceDiffers": "Bank: {{bank}} · Certain differs by {{diff}}", + "adoptBankBalance": "Adopt the bank's balance", + "adoptConfirm": "The balance of {{bank}} is taken as correct. The difference of {{diff}} moves into the opening balance – it stands for movements Certain never saw. Your transactions stay unchanged.", + "adopted": "Balance adopted." }, "accountTypes": { "girokonto": "Checking", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index 5174995..73fbee7 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -46,6 +46,7 @@ function TransferKeywords({ accounts }) { const [busy, setBusy] = useState(false); const accName = (id) => accounts.find((a) => a.id === id)?.name; + async function laden() { try { setWords(await api.listTransferKeywords()); } catch (err) { toast(err.message, "error"); } @@ -129,6 +130,24 @@ export default function Accounts() { const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen) const [busy, setBusy] = useState(false); + async function saldoUebernehmen(a) { + if (!(await confirm({ + title: t("accounts.adoptBankBalance"), + message: t("accounts.adoptConfirm", { + bank: formatMoney(a.bank_balance_cents, a.currency), + diff: formatMoney(a.balance_cents - a.bank_balance_cents, a.currency), + }), + confirmLabel: t("accounts.adoptBankBalance"), + }))) return; + try { + await api.adoptBankBalance(a.id); + toast(t("accounts.adopted")); + laden(); + } catch (e) { + toast(e.message, "error"); + } + } + async function laden() { try { setAccounts(await api.listAccounts()); @@ -265,7 +284,14 @@ export default function Accounts() { bank: formatMoney(a.bank_balance_cents, a.currency), diff: formatMoney(a.balance_cents - a.bank_balance_cents, a.currency), - })} + })}{" "} + {/* Die Bank kennt den wahren Stand. Uebernehmen legt + die Differenz in den Anfangsbestand – Buchungen + werden nicht angefasst. */} + )}