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:
@@ -678,6 +678,12 @@ class InboxItem(Base):
|
|||||||
transaction_id: Mapped[int | None] = mapped_column(
|
transaction_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True
|
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):
|
# Kennung der Quelle (Message-ID der Mail, Kennung des Kurzbefehls):
|
||||||
# schützt davor, dieselbe Meldung zweimal zu erfassen.
|
# schützt davor, dieselbe Meldung zweimal zu erfassen.
|
||||||
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|||||||
@@ -239,6 +239,39 @@ def _entkoppele_uebertraege(db: Session, acc: Account) -> None:
|
|||||||
db.flush()
|
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)
|
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_account(
|
def delete_account(
|
||||||
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
|||||||
@@ -537,6 +537,10 @@ class InboxItemOut(BaseModel):
|
|||||||
# das kann die Oberfläche einen Übertrag nicht von einer Ausgabe
|
# das kann die Oberfläche einen Übertrag nicht von einer Ausgabe
|
||||||
# unterscheiden und fragt weiter nach einer Kategorie.
|
# unterscheiden und fragt weiter nach einer Kategorie.
|
||||||
transfer_account_id: int | None = None
|
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
|
created_at: datetime | None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|||||||
@@ -309,6 +309,61 @@ def _betrag_cents(umsatz: dict) -> int | None:
|
|||||||
return cents
|
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]:
|
def _text(umsatz: dict, ausgabe: bool) -> tuple[str | None, str | None]:
|
||||||
"""Empfänger und Verwendungszweck. Bei einer Ausgabe zählt der Empfänger,
|
"""Empfänger und Verwendungszweck. Bei einer Ausgabe zählt der Empfänger,
|
||||||
bei einer Einnahme der Absender."""
|
bei einer Einnahme der Absender."""
|
||||||
@@ -530,6 +585,9 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
|
|||||||
if zeile.get("is_duplicate"):
|
if zeile.get("is_duplicate"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
waehrung = ((umsatz.get("transaction_amount") or {}).get("currency")
|
||||||
|
or konto.currency)
|
||||||
|
kurs, gegen_betrag, gegen_waehrung = _umrechnung(umsatz, waehrung)
|
||||||
eintrag = InboxItem(
|
eintrag = InboxItem(
|
||||||
owner_id=user.id,
|
owner_id=user.id,
|
||||||
source=InboxSource.bank,
|
source=InboxSource.bank,
|
||||||
@@ -537,12 +595,14 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
|
|||||||
account_id=konto.id,
|
account_id=konto.id,
|
||||||
date=tag,
|
date=tag,
|
||||||
amount_cents=cents,
|
amount_cents=cents,
|
||||||
currency=(umsatz.get("transaction_amount") or {}).get("currency")
|
currency=waehrung,
|
||||||
or konto.currency,
|
|
||||||
payee=payee,
|
payee=payee,
|
||||||
note=zweck,
|
note=zweck,
|
||||||
category_id=zeile.get("category_id"),
|
category_id=zeile.get("category_id"),
|
||||||
external_id=kennung,
|
external_id=kennung,
|
||||||
|
fx_rate=kurs,
|
||||||
|
counter_amount_cents=gegen_betrag,
|
||||||
|
counter_currency=gegen_waehrung,
|
||||||
)
|
)
|
||||||
db.add(eintrag)
|
db.add(eintrag)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|||||||
@@ -65,16 +65,30 @@ def ist_angebunden(db: Session, user: User, konto: Account) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _gegenbetrag(db: Session, konto: Account, ziel: Account, betrag: int,
|
def _gegenbetrag(db: Session, konto: Account, ziel: Account, betrag: int,
|
||||||
datum) -> tuple[int | None, bool]:
|
datum, eintrag: InboxItem | None = None,
|
||||||
"""Betrag auf der Zielseite und ob er BEKANNT oder geschaetzt ist.
|
) -> 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
|
Drei Stufen, absteigend nach Verlaesslichkeit:
|
||||||
Naeherung, denn die Bank rechnet mit ihrem eigenen Kurs.
|
|
||||||
|
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:
|
if ziel.currency == konto.currency:
|
||||||
return betrag, True
|
return betrag, None, True
|
||||||
umgerechnet = currency.convert(db, betrag, konto.currency, ziel.currency, datum)
|
|
||||||
return umgerechnet, False
|
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,
|
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,
|
def _lege_an(db: Session, user: User, konto: Account, ziel: Account,
|
||||||
betrag_signiert: int, ziel_betrag: int | None, datum,
|
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.
|
"""Die Uebertrags-Buchung, richtig herum.
|
||||||
|
|
||||||
Bei einem Zugang kennen wir den Betrag auf DIESEM Konto exakt – der gehoert
|
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,
|
to_account_id=nach.id,
|
||||||
amount_cents=von_betrag,
|
amount_cents=von_betrag,
|
||||||
to_amount_cents=nach_betrag,
|
to_amount_cents=nach_betrag,
|
||||||
|
fx_rate=kurs,
|
||||||
payee=payee,
|
payee=payee,
|
||||||
description=beschreibung,
|
description=beschreibung,
|
||||||
)
|
)
|
||||||
@@ -185,11 +201,11 @@ def aus_eingang(db: Session, user: User, eintrag: InboxItem, ziel: Account,
|
|||||||
raise UebertragFehler("Kein Konto zugeordnet")
|
raise UebertragFehler("Kein Konto zugeordnet")
|
||||||
|
|
||||||
betrag = abs(eintrag.amount_cents)
|
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)
|
_pruefe(db, user, konto, ziel, exakt, trotzdem)
|
||||||
|
|
||||||
txn = _lege_an(db, user, konto, ziel, eintrag.amount_cents, ziel_betrag,
|
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.transaction_id = txn.id
|
||||||
eintrag.status = InboxStatus.booked
|
eintrag.status = InboxStatus.booked
|
||||||
eintrag.category_id = None
|
eintrag.category_id = None
|
||||||
@@ -212,7 +228,7 @@ def aus_buchung(db: Session, user: User, txn: Transaction, ziel: Account,
|
|||||||
raise UebertragFehler("Buchung ohne Konto")
|
raise UebertragFehler("Buchung ohne Konto")
|
||||||
|
|
||||||
signiert = txn.amount_cents if txn.type == TxnType.income else -txn.amount_cents
|
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)
|
_pruefe(db, user, konto, ziel, exakt, trotzdem)
|
||||||
|
|
||||||
# Die vorhandene Buchung umschreiben statt eine zweite anzulegen: Alles, was
|
# Die vorhandene Buchung umschreiben statt eine zweite anzulegen: Alles, was
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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["geholt"] == 1, "der Umsatz kam an"
|
||||||
assert abruf["neu"] == 0, "und wurde als Gegenseite erkannt"
|
assert abruf["neu"] == 0, "und wurde als Gegenseite erkannt"
|
||||||
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == vorher
|
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
|
||||||
|
|||||||
@@ -302,3 +302,43 @@ def test_nachtraeglicher_uebertrag_dreht_eine_einnahme_um(admin):
|
|||||||
assert r.json()["to_account_id"] == hier["id"]
|
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/{hier['id']}").json()["balance_cents"] == betrag
|
||||||
assert admin.get(f"/api/accounts/{dort['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"]
|
||||||
|
|||||||
@@ -231,6 +231,10 @@ export const api = {
|
|||||||
// Nur dieses eine Konto abrufen – ein Abruf bucht, und wer ein Konto holt,
|
// Nur dieses eine Konto abrufen – ein Abruf bucht, und wer ein Konto holt,
|
||||||
// will das andere nicht angefasst haben.
|
// will das andere nicht angefasst haben.
|
||||||
syncBankLink: (linkId) => request(`/bank/links/${linkId}/sync`, { method: "POST" }),
|
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) =>
|
setBankPoll: (id, minutes) =>
|
||||||
request(`/bank/connections/${id}/takt?minutes=${minutes}`, { method: "PUT" }),
|
request(`/bank/connections/${id}/takt?minutes=${minutes}`, { method: "PUT" }),
|
||||||
deleteBankConnection: (id) => request(`/bank/connections/${id}`, { method: "DELETE" }),
|
deleteBankConnection: (id) => request(`/bank/connections/${id}`, { method: "DELETE" }),
|
||||||
|
|||||||
@@ -174,7 +174,10 @@
|
|||||||
"moveUp": "Nach oben",
|
"moveUp": "Nach oben",
|
||||||
"moveDown": "Nach unten",
|
"moveDown": "Nach unten",
|
||||||
"bankBalanceMatches": "Saldo stimmt mit der Bank überein",
|
"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": {
|
"accountTypes": {
|
||||||
"girokonto": "Girokonto",
|
"girokonto": "Girokonto",
|
||||||
|
|||||||
@@ -174,7 +174,10 @@
|
|||||||
"moveUp": "Move up",
|
"moveUp": "Move up",
|
||||||
"moveDown": "Move down",
|
"moveDown": "Move down",
|
||||||
"bankBalanceMatches": "Balance matches the bank",
|
"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": {
|
"accountTypes": {
|
||||||
"girokonto": "Checking",
|
"girokonto": "Checking",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function TransferKeywords({ accounts }) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const accName = (id) => accounts.find((a) => a.id === id)?.name;
|
const accName = (id) => accounts.find((a) => a.id === id)?.name;
|
||||||
|
|
||||||
|
|
||||||
async function laden() {
|
async function laden() {
|
||||||
try { setWords(await api.listTransferKeywords()); }
|
try { setWords(await api.listTransferKeywords()); }
|
||||||
catch (err) { toast(err.message, "error"); }
|
catch (err) { toast(err.message, "error"); }
|
||||||
@@ -129,6 +130,24 @@ export default function Accounts() {
|
|||||||
const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen)
|
const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen)
|
||||||
const [busy, setBusy] = useState(false);
|
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() {
|
async function laden() {
|
||||||
try {
|
try {
|
||||||
setAccounts(await api.listAccounts());
|
setAccounts(await api.listAccounts());
|
||||||
@@ -265,7 +284,14 @@ export default function Accounts() {
|
|||||||
bank: formatMoney(a.bank_balance_cents, a.currency),
|
bank: formatMoney(a.bank_balance_cents, a.currency),
|
||||||
diff: formatMoney(a.balance_cents - a.bank_balance_cents,
|
diff: formatMoney(a.balance_cents - a.bank_balance_cents,
|
||||||
a.currency),
|
a.currency),
|
||||||
})}
|
})}{" "}
|
||||||
|
{/* Die Bank kennt den wahren Stand. Uebernehmen legt
|
||||||
|
die Differenz in den Anfangsbestand – Buchungen
|
||||||
|
werden nicht angefasst. */}
|
||||||
|
<button type="button" className="btn sm"
|
||||||
|
onClick={() => saldoUebernehmen(a)}>
|
||||||
|
{t("accounts.adoptBankBalance")}
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user