feat: Konto-Saldo-Anker mit ankerbasierter Saldenrechnung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +1,91 @@
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.tables import Account, Statement, Transaction
|
||||
from app.models.tables import Account, Transaction
|
||||
from app.services.balances import account_balance
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def test_account_crud_and_balance(client, db):
|
||||
# Portiert von Statement-closing-Logik auf Konto-Saldo-Anker (Ausbaustufe 3
|
||||
# Task 1): frueher lieferte die closing_balance des juengsten bestaetigten
|
||||
# Statements die Basis, jetzt uebernimmt der Anker (Datum+Betrag am Konto)
|
||||
# dieselbe Rolle. `at` wird explizit gesetzt statt auf date.today() zu
|
||||
# vertrauen, damit der Test unabhaengig vom Ausfuehrungsdatum ist.
|
||||
r = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE02120300000000202051",
|
||||
"name": "Giro", "type": "giro"})
|
||||
assert r.status_code == 201
|
||||
acc_id = r.json()["id"]
|
||||
db.add(Statement(filename="a.pdf", bank="dkb", account_id=acc_id,
|
||||
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
|
||||
opening_balance=Decimal("0"), closing_balance=Decimal("100.00"),
|
||||
status="confirmed"))
|
||||
acc = db.get(Account, acc_id)
|
||||
acc.anchor_date = date(2026, 6, 30)
|
||||
acc.anchor_balance = Decimal("100.00")
|
||||
db.add(Transaction(account_id=acc_id, booking_date=date(2026, 7, 2),
|
||||
amount=Decimal("-30.00"), purpose="Bar", status="confirmed",
|
||||
dedup_hash="x1"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc_id)
|
||||
assert account_balance(db, acc) == Decimal("70.00")
|
||||
assert account_balance(db, acc, at=date(2026, 7, 15)) == Decimal("70.00")
|
||||
|
||||
|
||||
def test_account_balance_anchor_forward_and_backward(client, db):
|
||||
# Spec-Interface (Plan Ausbaustufe 3 Task 1): anchor_balance +
|
||||
# Sum(tx: anchor_date < booking_date <= at) - Sum(tx: at < booking_date
|
||||
# <= anchor_date). Buchungen VOR und NACH dem Anker duerfen sich nicht
|
||||
# gegenseitig beeinflussen.
|
||||
acc = Account(bank="dkb", iban="DE-ANCHOR-1", name="Anker-Konto", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
anchor_date = date(2026, 6, 30)
|
||||
acc.anchor_date = anchor_date
|
||||
acc.anchor_balance = Decimal("500.00")
|
||||
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 6, 29),
|
||||
amount=Decimal("-20.00"), purpose="vor Anker",
|
||||
status="confirmed", dedup_hash="x2"))
|
||||
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 7, 1),
|
||||
amount=Decimal("50.00"), purpose="nach Anker",
|
||||
status="confirmed", dedup_hash="x3"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc.id)
|
||||
|
||||
assert account_balance(db, acc, at=anchor_date) == Decimal("500.00")
|
||||
assert account_balance(db, acc, at=anchor_date + timedelta(days=2)) == Decimal("550.00")
|
||||
assert account_balance(db, acc, at=anchor_date - timedelta(days=2)) == Decimal("520.00")
|
||||
|
||||
|
||||
def test_account_balance_anchor_day_transaction_convention(client, db):
|
||||
# Konvention (Plan Step 5): der Anker gilt PER TAGESENDE des Ankerdatums.
|
||||
# Eine Buchung AM Ankertag ist im Anker bereits enthalten -> zaehlt NICHT
|
||||
# zur Vorwaertssumme, wohl aber zur Rueckwaertssumme.
|
||||
acc = Account(bank="dkb", iban="DE-ANCHOR-2", name="Anker-Konto 2", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
anchor_date = date(2026, 6, 30)
|
||||
acc.anchor_date = anchor_date
|
||||
acc.anchor_balance = Decimal("200.00")
|
||||
db.add(Transaction(account_id=acc.id, booking_date=anchor_date,
|
||||
amount=Decimal("15.00"), purpose="am Ankertag",
|
||||
status="confirmed", dedup_hash="x4"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc.id)
|
||||
|
||||
assert account_balance(db, acc, at=anchor_date + timedelta(days=1)) == Decimal("200.00")
|
||||
assert account_balance(db, acc, at=anchor_date - timedelta(days=1)) == Decimal("185.00")
|
||||
|
||||
|
||||
def test_account_without_anchor_uses_zero_base(client, db):
|
||||
# Ohne Anker: Summe aller bestaetigten Buchungen bis `at` (Basis 0) -
|
||||
# bisheriges Verhalten bleibt fuer Konten ohne Anker erhalten.
|
||||
acc = Account(bank="dkb", iban="DE-NOANCHOR", name="Kein Anker", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 7, 1),
|
||||
amount=Decimal("42.00"), purpose="ohne Anker",
|
||||
status="confirmed", dedup_hash="x5"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc.id)
|
||||
assert account_balance(db, acc, at=date(2026, 7, 15)) == Decimal("42.00")
|
||||
|
||||
|
||||
def test_manual_transaction_dedup(client):
|
||||
@@ -62,6 +125,40 @@ def test_patch_account_name(client):
|
||||
json={"name": " "}).status_code == 422
|
||||
|
||||
|
||||
def test_patch_account_anchor_set_and_clear(client):
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE72", "name": "Anker-Test", "type": "giro"}).json()
|
||||
assert acc["anchor_date"] is None
|
||||
assert acc["anchor_balance"] is None
|
||||
|
||||
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": "2026-06-30", "anchor_balance": "500.00"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["anchor_date"] == "2026-06-30"
|
||||
assert Decimal(body["anchor_balance"]) == Decimal("500.00")
|
||||
|
||||
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": None, "anchor_balance": None})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["anchor_date"] is None
|
||||
assert body["anchor_balance"] is None
|
||||
|
||||
|
||||
def test_patch_account_anchor_requires_both_fields(client):
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE73", "name": "Anker-422", "type": "giro"}).json()
|
||||
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": "2026-06-30"}).status_code == 422
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_balance": "500.00"}).status_code == 422
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": "2026-06-30",
|
||||
"anchor_balance": None}).status_code == 422
|
||||
|
||||
|
||||
def test_patch_transaction_category(client):
|
||||
cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json()
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.tables import Account
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def test_pages_require_login(client):
|
||||
for path in ("/", "/buchungen", "/import", "/planung", "/hilfe"):
|
||||
@@ -18,6 +22,26 @@ def test_pages_render_after_login(client):
|
||||
assert "Gebrauchsanleitung" in client.get("/hilfe").text
|
||||
|
||||
|
||||
def test_index_shows_account_anchor(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
with_anchor = Account(bank="dkb", iban="DE-GUI-1", name="Mit Anker", type="giro")
|
||||
without_anchor = Account(bank="vr", iban="DE-GUI-2", name="Ohne Anker", type="giro")
|
||||
db.add_all([with_anchor, without_anchor])
|
||||
db.flush()
|
||||
with_anchor.anchor_date = date(2026, 6, 30)
|
||||
with_anchor.anchor_balance = Decimal("321.00")
|
||||
db.commit()
|
||||
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "Anker: 321.00" in r.text
|
||||
assert "30.06.2026" in r.text
|
||||
assert "kein Anker" in r.text
|
||||
# Inline-Pflegeformular fuer den Anker muss je Konto vorhanden sein.
|
||||
assert 'name="anchor_date"' in r.text
|
||||
assert 'name="anchor_balance"' in r.text
|
||||
|
||||
|
||||
def test_import_page_has_dropzone_and_list(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/import")
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.tables import Account, Category, RecurringItem, Statement, Transaction
|
||||
from app.models.tables import Account, Category, RecurringItem, Transaction
|
||||
from app.services.suggestions import suggest_recurring
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def _seed_balance(db, amount="1000.00"):
|
||||
# Portiert von Statement-closing-Logik auf Konto-Saldo-Anker (Ausbaustufe 3
|
||||
# Task 1): der Anker am Konto uebernimmt die Rolle der frueheren
|
||||
# Statement.closing_balance als Saldo-Basis.
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
db.add(Statement(filename="s.pdf", bank="dkb", account_id=acc.id,
|
||||
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
|
||||
opening_balance=Decimal("0"), closing_balance=Decimal(amount),
|
||||
status="confirmed"))
|
||||
acc.anchor_date = date(2026, 6, 30)
|
||||
acc.anchor_balance = Decimal(amount)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user