150 lines
6.7 KiB
Python
150 lines
6.7 KiB
Python
from datetime import date
|
|
from decimal import Decimal
|
|
|
|
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()
|
|
acc.anchor_date = date(2026, 6, 30)
|
|
acc.anchor_balance = Decimal(amount)
|
|
db.commit()
|
|
|
|
|
|
def test_scenario_projection(client, db):
|
|
_seed_balance(db)
|
|
client.post("/api/recurring", headers=H, json={
|
|
"name": "Miete", "amount": "-600.00", "rhythm": "monthly", "due_day": 1})
|
|
sc = client.post("/api/scenarios", headers=H, json={"name": "Basis"}).json()
|
|
r = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
|
|
params={"horizon_days": 92, "start_date": "2026-07-15"})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
# 3 Mietzahlungen (01.08., 01.09., 01.10.): 400 -> -200 -> -800
|
|
assert Decimal(body["low_point_balance"]) == Decimal("-800.00")
|
|
assert body["below_zero_date"] == "2026-09-01"
|
|
assert len(body["series"]) == 92
|
|
|
|
|
|
def test_projektion_mit_ende_modifier_und_szenario_einmalzahlung(client, db):
|
|
_seed_balance(db)
|
|
rec = client.post("/api/recurring", headers=H, json={
|
|
"name": "Miete-Proj", "amount": "-1000.00", "rhythm": "monthly",
|
|
"due_day": 1}).json()
|
|
sc = client.post("/api/scenarios", headers=H,
|
|
json={"name": "BestCase-Test", "include_planned": False}).json()
|
|
client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
|
"target_type": "recurring", "target_id": rec["id"], "kind": "ende",
|
|
"end_date": "2026-08-31"})
|
|
client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
|
|
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"})
|
|
r = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
|
|
params={"horizon_days": 150, "start_date": "2026-07-15"})
|
|
assert r.status_code == 200
|
|
series = {p["day"]: Decimal(p["balance"]) for p in r.json()["series"]}
|
|
# Start 1000; +5000 am 30.07.; Miete nur noch am 01.08. (ende 31.08.);
|
|
# include_planned=False, aber die SZENARIO-Zahlung zaehlt trotzdem.
|
|
assert series["2026-07-30"] == Decimal("6000.00")
|
|
assert series["2026-08-01"] == Decimal("5000.00")
|
|
assert series["2026-12-01"] == Decimal("5000.00") # keine Miete mehr ab Sept.
|
|
|
|
|
|
def test_loan_in_scenario_keeps_balance_positive(client, db):
|
|
_seed_balance(db)
|
|
client.post("/api/recurring", headers=H, json={
|
|
"name": "Miete", "amount": "-600.00", "rhythm": "monthly", "due_day": 1})
|
|
loan = client.post("/api/loans", headers=H, json={
|
|
"name": "K1", "principal": "5000.00", "annual_rate_pct": "6.0",
|
|
"term_months": 48, "payout_date": "2026-07-20",
|
|
"repayment_type": "annuity"}).json()
|
|
sc = client.post("/api/scenarios", headers=H, json={"name": "Kredit"}).json()
|
|
client.post(f"/api/scenarios/{sc['id']}/loans/{loan['id']}", headers=H)
|
|
body = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
|
|
params={"horizon_days": 92, "start_date": "2026-07-15"}).json()
|
|
assert Decimal(body["low_point_balance"]) > Decimal("0")
|
|
|
|
|
|
def _tx(acc, d, amount, counterparty, dedup, category_id=None):
|
|
return Transaction(account_id=acc.id, booking_date=d, amount=Decimal(amount),
|
|
purpose="", counterparty=counterparty, status="confirmed",
|
|
dedup_hash=dedup, category_id=category_id)
|
|
|
|
|
|
def test_suggest_recurring_three_consecutive_months_with_year_wrap(db):
|
|
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
|
db.add(acc)
|
|
db.flush()
|
|
cat = Category(name="Miete")
|
|
db.add(cat)
|
|
db.flush()
|
|
# Dez 2025 -> Jan 2026 -> Feb 2026: 3 aufeinanderfolgende Monate ueber den
|
|
# Jahreswechsel hinweg (prueft die Monats-Linearisierung y*12+m).
|
|
db.add(_tx(acc, date(2025, 12, 1), "-600.00", "Vermieter", "h1", cat.id))
|
|
db.add(_tx(acc, date(2026, 1, 15), "-600.00", "Vermieter", "h2", cat.id))
|
|
db.add(_tx(acc, date(2026, 2, 28), "-600.00", "Vermieter", "h3", cat.id))
|
|
db.commit()
|
|
|
|
out = suggest_recurring(db)
|
|
|
|
# Median der Tage [1, 15, 28] = 15; Betrag unveraendert uebernommen.
|
|
assert out == [{"name": "Vermieter", "amount": Decimal("-600.00"),
|
|
"rhythm": "monthly", "due_day": 15, "category_id": cat.id}]
|
|
|
|
|
|
def test_suggest_recurring_two_months_no_suggestion(db):
|
|
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
|
db.add(acc)
|
|
db.flush()
|
|
db.add(_tx(acc, date(2026, 3, 10), "-50.00", "Zweimonatig", "h1"))
|
|
db.add(_tx(acc, date(2026, 4, 10), "-50.00", "Zweimonatig", "h2"))
|
|
db.commit()
|
|
|
|
assert suggest_recurring(db) == []
|
|
|
|
|
|
def test_recurring_ende_vor_start_wird_abgelehnt(client):
|
|
r = client.post("/api/recurring", headers=H, json={
|
|
"name": "Falschrum", "amount": "-10.00", "rhythm": "monthly", "due_day": 1,
|
|
"start_date": "2026-10-01", "end_date": "2026-09-01"})
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_recurring_start_ende_roundtrip_und_patch_validierung(client):
|
|
r = client.post("/api/recurring", headers=H, json={
|
|
"name": "Befristet", "amount": "-10.00", "rhythm": "monthly", "due_day": 1,
|
|
"start_date": "2026-01-01", "end_date": "2026-12-31"})
|
|
assert r.status_code == 201
|
|
item = r.json()
|
|
assert item["start_date"] == "2026-01-01" and item["end_date"] == "2026-12-31"
|
|
# PATCH, der Ende vor den (bestehenden) Start schieben will -> 422:
|
|
bad = client.patch(f"/api/recurring/{item['id']}", headers=H,
|
|
json={"end_date": "2025-06-30"})
|
|
assert bad.status_code == 422
|
|
# Datum explizit loeschen (null) ist erlaubt:
|
|
ok = client.patch(f"/api/recurring/{item['id']}", headers=H,
|
|
json={"end_date": None})
|
|
assert ok.status_code == 200 and ok.json()["end_date"] is None
|
|
|
|
|
|
def test_suggest_recurring_excludes_existing_recurring_item(db):
|
|
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
|
db.add(acc)
|
|
db.flush()
|
|
db.add(_tx(acc, date(2026, 1, 5), "-30.00", "Streaming", "h1"))
|
|
db.add(_tx(acc, date(2026, 2, 5), "-30.00", "Streaming", "h2"))
|
|
db.add(_tx(acc, date(2026, 3, 5), "-30.00", "Streaming", "h3"))
|
|
db.add(RecurringItem(name="Streaming", amount=Decimal("-30.00"),
|
|
rhythm="monthly", due_day=5))
|
|
db.commit()
|
|
|
|
# Gleicher Name + Betrag wie ein bereits vorhandenes RecurringItem -> ausgelassen.
|
|
assert suggest_recurring(db) == []
|