feat: Kurs der Bank statt EZB-Naeherung, und Saldo uebernehmen

Bei einer Fremdwaehrungs-Zahlung nennt die Bank oft ihren echten Kurs und den
Betrag in der Ausgangswaehrung. Certain warf das weg und naeherte mit dem
EZB-Kurs. Das faellt genau dort auf, wo es teuer ist: Die spaeter gelieferte
Gegenseite eines Uebertrags laesst sich mit einem geschaetzten Betrag nicht als
dieselbe Bewegung wiedererkennen.

Der Uebertrag waehlt jetzt in drei Stufen: der von der Bank genannte
Gegenbetrag (exakt), sonst ihr Kurs, sonst die EZB. Nur die erste Stufe gilt
als bekannt – daran haengt, ob bei einem angebundenen Zielkonto nachgefragt
wird.

Der Parser klopft mehrere Formen ab, weil die Feldnamen je Anbindung
verschieden sind, und setzt still nichts, wenn keine passt. Keine dieser Formen
ist gegen echte Daten geprueft; ein falscher Kurs saehe aus wie ein richtiger,
deshalb wird lieber gar keiner gesetzt.

Ausserdem ein Knopf auf der Kontenseite: den Saldo der Bank als richtig
uebernehmen. Die Differenz wandert in den ANFANGSBESTAND – sie steht fuer
Bewegungen, die Certain nie gesehen hat, und genau dafuer ist er da. Eine
Korrekturbuchung stuende dagegen fuer immer in der Liste und liefe durch jede
Auswertung. Die Buchungen bleiben unangetastet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-27 10:18:33 +02:00
co-authored by Claude Opus 5
parent 8ceee5c5b1
commit 8309f58a67
12 changed files with 297 additions and 16 deletions
+51
View File
@@ -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
+40
View File
@@ -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"]