feat: Konto-Saldo-Anker mit ankerbasierter Saldenrechnung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
34
finance/alembic/versions/1ef6a356f028_konto_anker.py
Normal file
34
finance/alembic/versions/1ef6a356f028_konto_anker.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""konto anker
|
||||||
|
|
||||||
|
Revision ID: 1ef6a356f028
|
||||||
|
Revises: b2b1f5a18a74
|
||||||
|
Create Date: 2026-07-19 21:18:41.888568
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '1ef6a356f028'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'b2b1f5a18a74'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('accounts', sa.Column('anchor_date', sa.Date(), nullable=True))
|
||||||
|
op.add_column('accounts', sa.Column('anchor_balance', sa.Numeric(precision=12, scale=2), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('accounts', 'anchor_balance')
|
||||||
|
op.drop_column('accounts', 'anchor_date')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -16,6 +16,12 @@ class Account(Base):
|
|||||||
iban: Mapped[str] = mapped_column(String(34), unique=True)
|
iban: Mapped[str] = mapped_column(String(34), unique=True)
|
||||||
name: Mapped[str] = mapped_column(String(100))
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
type: Mapped[str] = mapped_column(String(20), default="giro")
|
type: Mapped[str] = mapped_column(String(20), default="giro")
|
||||||
|
# Konto-Saldo-Anker (Ausbaustufe 3 Task 1): ersetzt die bisherige
|
||||||
|
# Statement-closing-Logik als Basis der Saldenrechnung. Beide Felder sind
|
||||||
|
# nur gemeinsam gesetzt oder gemeinsam NULL (durchgesetzt in
|
||||||
|
# app.routers.accounts.AccountPatch).
|
||||||
|
anchor_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
anchor_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class Category(Base):
|
class Category(Base):
|
||||||
|
|||||||
@@ -1,32 +1,45 @@
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
# Per-account starting balance: the opening_balance of each account's earliest
|
# Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the
|
||||||
# confirmed statement (if any). Used to seed the cumulative sums below so the
|
# same math as app.services.balances.account_balance, expressed as a single
|
||||||
# Grafana balance charts show the real account balance, not just the running
|
# constant "base" per account so it can be added to a plain (anchor-agnostic)
|
||||||
# sum of imported transactions (which would start at zero and ignore the
|
# running sum of transaction amounts:
|
||||||
# balance that existed before the first imported statement).
|
#
|
||||||
_ACCOUNT_BASE = """
|
# balance(day) = anchor_balance + Sum(tx: anchor_date < booking_date <= day)
|
||||||
SELECT DISTINCT ON (account_id) account_id, opening_balance
|
# - Sum(tx: day < booking_date <= anchor_date)
|
||||||
FROM statements
|
# = anchor_balance - cumsum(<=anchor_date) + cumsum(<=day)
|
||||||
WHERE status = 'confirmed' AND opening_balance IS NOT NULL
|
# = base + cumsum(<=day)
|
||||||
ORDER BY account_id, period_start NULLS LAST, id
|
#
|
||||||
|
# ... which holds for `day` both before and after anchor_date, since the
|
||||||
|
# subtracted/added transaction windows collapse into the same telescoping
|
||||||
|
# sum. Accounts without an anchor get base=0 (via COALESCE below), matching
|
||||||
|
# account_balance's "Ohne Anker: Summe ab 0" fallback.
|
||||||
|
_ACCOUNT_OFFSET = """
|
||||||
|
SELECT a.id AS account_id,
|
||||||
|
a.anchor_balance - COALESCE((
|
||||||
|
SELECT SUM(t2.amount) FROM transactions t2
|
||||||
|
WHERE t2.account_id = a.id AND t2.status = 'confirmed'
|
||||||
|
AND t2.booking_date <= a.anchor_date
|
||||||
|
), 0) AS base
|
||||||
|
FROM accounts a
|
||||||
|
WHERE a.anchor_date IS NOT NULL AND a.anchor_balance IS NOT NULL
|
||||||
"""
|
"""
|
||||||
|
|
||||||
VIEWS: dict[str, str] = {
|
VIEWS: dict[str, str] = {
|
||||||
"v_balance_history": f"""
|
"v_balance_history": f"""
|
||||||
SELECT t.booking_date AS day, a.name AS account,
|
SELECT t.booking_date AS day, a.name AS account,
|
||||||
COALESCE(b.opening_balance, 0)
|
COALESCE(b.base, 0)
|
||||||
+ SUM(SUM(t.amount)) OVER (PARTITION BY a.id
|
+ SUM(SUM(t.amount)) OVER (PARTITION BY a.id
|
||||||
ORDER BY t.booking_date) AS balance
|
ORDER BY t.booking_date) AS balance
|
||||||
FROM transactions t JOIN accounts a ON a.id = t.account_id
|
FROM transactions t JOIN accounts a ON a.id = t.account_id
|
||||||
LEFT JOIN ({_ACCOUNT_BASE}) b ON b.account_id = a.id
|
LEFT JOIN ({_ACCOUNT_OFFSET}) b ON b.account_id = a.id
|
||||||
WHERE t.status = 'confirmed'
|
WHERE t.status = 'confirmed'
|
||||||
GROUP BY a.id, a.name, t.booking_date, b.opening_balance""",
|
GROUP BY a.id, a.name, t.booking_date, b.base""",
|
||||||
"v_balance_total": f"""
|
"v_balance_total": f"""
|
||||||
SELECT booking_date AS day,
|
SELECT booking_date AS day,
|
||||||
(SELECT COALESCE(SUM(opening_balance), 0)
|
(SELECT COALESCE(SUM(base), 0)
|
||||||
FROM ({_ACCOUNT_BASE}) s)
|
FROM ({_ACCOUNT_OFFSET}) s)
|
||||||
+ SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance
|
+ SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance
|
||||||
FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""",
|
FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""",
|
||||||
"v_monthly_by_category": """
|
"v_monthly_by_category": """
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
@@ -25,14 +26,23 @@ class AccountOut(AccountIn):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: int
|
id: int
|
||||||
balance: Decimal = Decimal("0")
|
balance: Decimal = Decimal("0")
|
||||||
|
anchor_date: date | None = None
|
||||||
|
anchor_balance: Decimal | None = None
|
||||||
|
|
||||||
|
|
||||||
class AccountPatch(BaseModel):
|
class AccountPatch(BaseModel):
|
||||||
name: str
|
# Alle Felder optional; model_fields_set entscheidet, was tatsaechlich
|
||||||
|
# geaendert wird (Muster wie TransactionPatch.category_id). anchor_date
|
||||||
|
# und anchor_balance muessen gemeinsam gesetzt oder gemeinsam null sein.
|
||||||
|
name: str | None = None
|
||||||
|
anchor_date: date | None = None
|
||||||
|
anchor_balance: Decimal | None = None
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
def name_not_blank(cls, v: str) -> str:
|
def name_not_blank(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
v = v.strip()
|
v = v.strip()
|
||||||
if not v or len(v) > 100:
|
if not v or len(v) > 100:
|
||||||
raise ValueError("Name muss 1–100 Zeichen lang sein")
|
raise ValueError("Name muss 1–100 Zeichen lang sein")
|
||||||
@@ -75,7 +85,25 @@ def patch_account(account_id: int, data: AccountPatch, session: Session = Depend
|
|||||||
acc = session.get(Account, account_id)
|
acc = session.get(Account, account_id)
|
||||||
if acc is None:
|
if acc is None:
|
||||||
raise HTTPException(404, "Konto nicht gefunden")
|
raise HTTPException(404, "Konto nicht gefunden")
|
||||||
acc.name = data.name
|
|
||||||
|
# Validierung zuerst, VOR jeder Mutation - sonst haengt bei einem 422 auf
|
||||||
|
# dem Anker eine bereits geschriebene name-Aenderung im Session-State.
|
||||||
|
fields = data.model_fields_set
|
||||||
|
if "name" in fields and data.name is None:
|
||||||
|
raise HTTPException(422, "Name darf nicht leer sein")
|
||||||
|
|
||||||
|
anchor_fields = {"anchor_date", "anchor_balance"} & fields
|
||||||
|
if anchor_fields and (
|
||||||
|
anchor_fields != {"anchor_date", "anchor_balance"}
|
||||||
|
or (data.anchor_date is None) != (data.anchor_balance is None)):
|
||||||
|
raise HTTPException(422, "Anker braucht Datum und Betrag (oder beide leeren)")
|
||||||
|
|
||||||
|
if "name" in fields:
|
||||||
|
acc.name = data.name
|
||||||
|
if anchor_fields:
|
||||||
|
acc.anchor_date = data.anchor_date
|
||||||
|
acc.anchor_balance = data.anchor_balance
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(acc)
|
session.refresh(acc)
|
||||||
item = AccountOut.model_validate(acc)
|
item = AccountOut.model_validate(acc)
|
||||||
|
|||||||
@@ -1,26 +1,53 @@
|
|||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.tables import Account, Statement, Transaction
|
from app.models.tables import Account, Transaction
|
||||||
|
|
||||||
|
|
||||||
def account_balance(session: Session, account: Account) -> Decimal:
|
def _confirmed_sum(session: Session, account_id: int,
|
||||||
stmt = session.execute(
|
after: date | None, upto: date | None) -> Decimal:
|
||||||
select(Statement).where(Statement.account_id == account.id,
|
"""Summe bestaetigter Buchungen fuer `account_id` mit `after < booking_date
|
||||||
Statement.status == "confirmed")
|
<= upto` (Raender werden nur gesetzt, wenn nicht None)."""
|
||||||
.order_by(Statement.period_end.desc())).scalars().first()
|
|
||||||
q = select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
q = select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||||
Transaction.account_id == account.id, Transaction.status == "confirmed")
|
Transaction.account_id == account_id, Transaction.status == "confirmed")
|
||||||
if stmt is not None:
|
if after is not None:
|
||||||
q = q.where(Transaction.booking_date > stmt.period_end)
|
q = q.where(Transaction.booking_date > after)
|
||||||
base = stmt.closing_balance
|
if upto is not None:
|
||||||
else:
|
q = q.where(Transaction.booking_date <= upto)
|
||||||
base = Decimal("0")
|
return Decimal(session.execute(q).scalar_one())
|
||||||
return Decimal(base) + Decimal(session.execute(q).scalar_one())
|
|
||||||
|
|
||||||
|
|
||||||
def total_balance(session: Session) -> Decimal:
|
def account_balance(session: Session, account: Account, at: date | None = None) -> Decimal:
|
||||||
|
"""Kontostand am Ende des Tages `at` (Default: heute).
|
||||||
|
|
||||||
|
Konto-Saldo-Anker (Ausbaustufe 3 Task 1) ersetzt die bisherige
|
||||||
|
Statement-closing-Logik ersatzlos. Konvention: der Anker gilt PER
|
||||||
|
TAGESENDE des Ankerdatums - er enthaelt bereits alle Buchungen bis
|
||||||
|
einschliesslich diesem Tag.
|
||||||
|
|
||||||
|
Mit Anker: anchor_balance + Sum(tx: anchor_date < booking_date <= at)
|
||||||
|
- Sum(tx: at < booking_date <= anchor_date)
|
||||||
|
(jeweils nur eine der beiden Summen ist nicht-leer, je nachdem ob `at`
|
||||||
|
vor oder nach dem Anker liegt; bei at == anchor_date sind beide leer.)
|
||||||
|
|
||||||
|
Ohne Anker: Sum(tx: booking_date <= at) - bisheriges Verhalten mit Basis 0.
|
||||||
|
"""
|
||||||
|
if at is None:
|
||||||
|
at = date.today()
|
||||||
|
|
||||||
|
if account.anchor_date is not None and account.anchor_balance is not None:
|
||||||
|
anchor_date = account.anchor_date
|
||||||
|
anchor_balance = Decimal(account.anchor_balance)
|
||||||
|
if at >= anchor_date:
|
||||||
|
return anchor_balance + _confirmed_sum(session, account.id, anchor_date, at)
|
||||||
|
return anchor_balance - _confirmed_sum(session, account.id, at, anchor_date)
|
||||||
|
|
||||||
|
return _confirmed_sum(session, account.id, None, at)
|
||||||
|
|
||||||
|
|
||||||
|
def total_balance(session: Session, at: date | None = None) -> Decimal:
|
||||||
accounts = session.execute(select(Account)).scalars().all()
|
accounts = session.execute(select(Account)).scalars().all()
|
||||||
return sum((account_balance(session, a) for a in accounts), Decimal("0"))
|
return sum((account_balance(session, a, at) for a in accounts), Decimal("0"))
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Konto</th><th>Bank</th><th>Saldo</th><th>Umbenennen</th></tr>
|
<tr>
|
||||||
|
<th>Konto</th><th>Bank</th><th>Saldo</th><th>Anker</th>
|
||||||
|
<th>Umbenennen</th><th>Anker pflegen</th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for account, balance in balances %}
|
{% for account, balance in balances %}
|
||||||
@@ -24,6 +27,13 @@
|
|||||||
<td class="account">{{ account.name }}</td>
|
<td class="account">{{ account.name }}</td>
|
||||||
<td>{{ account.bank }}</td>
|
<td>{{ account.bank }}</td>
|
||||||
<td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
|
<td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
|
||||||
|
<td>
|
||||||
|
{% if account.anchor_date is not none and account.anchor_balance is not none %}
|
||||||
|
Anker: {{ '%.2f'|format(account.anchor_balance) }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
|
||||||
|
{% else %}
|
||||||
|
kein Anker
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
|
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
|
||||||
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
@@ -31,9 +41,19 @@
|
|||||||
<button type="submit">Umbenennen</button>
|
<button type="submit">Umbenennen</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
|
||||||
|
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<input type="date" name="anchor_date"
|
||||||
|
value="{{ account.anchor_date.isoformat() if account.anchor_date is not none else '' }}" required>
|
||||||
|
<input type="text" name="anchor_balance" placeholder="500.00"
|
||||||
|
value="{{ account.anchor_balance if account.anchor_balance is not none else '' }}" required>
|
||||||
|
<button type="submit">Anker setzen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="4">Keine Konten angelegt.</td></tr>
|
<tr><td colspan="6">Keine Konten angelegt.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,28 +1,91 @@
|
|||||||
from datetime import date
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
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
|
from app.services.balances import account_balance
|
||||||
|
|
||||||
H = {"Authorization": "Bearer test-key"}
|
H = {"Authorization": "Bearer test-key"}
|
||||||
|
|
||||||
|
|
||||||
def test_account_crud_and_balance(client, db):
|
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={
|
r = client.post("/api/accounts", headers=H, json={
|
||||||
"bank": "DKB", "iban": "DE02120300000000202051",
|
"bank": "DKB", "iban": "DE02120300000000202051",
|
||||||
"name": "Giro", "type": "giro"})
|
"name": "Giro", "type": "giro"})
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
acc_id = r.json()["id"]
|
acc_id = r.json()["id"]
|
||||||
db.add(Statement(filename="a.pdf", bank="dkb", account_id=acc_id,
|
acc = db.get(Account, acc_id)
|
||||||
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
|
acc.anchor_date = date(2026, 6, 30)
|
||||||
opening_balance=Decimal("0"), closing_balance=Decimal("100.00"),
|
acc.anchor_balance = Decimal("100.00")
|
||||||
status="confirmed"))
|
|
||||||
db.add(Transaction(account_id=acc_id, booking_date=date(2026, 7, 2),
|
db.add(Transaction(account_id=acc_id, booking_date=date(2026, 7, 2),
|
||||||
amount=Decimal("-30.00"), purpose="Bar", status="confirmed",
|
amount=Decimal("-30.00"), purpose="Bar", status="confirmed",
|
||||||
dedup_hash="x1"))
|
dedup_hash="x1"))
|
||||||
db.commit()
|
db.commit()
|
||||||
acc = db.get(Account, acc_id)
|
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):
|
def test_manual_transaction_dedup(client):
|
||||||
@@ -62,6 +125,40 @@ def test_patch_account_name(client):
|
|||||||
json={"name": " "}).status_code == 422
|
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):
|
def test_patch_transaction_category(client):
|
||||||
cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json()
|
cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json()
|
||||||
acc = client.post("/api/accounts", headers=H, json={
|
acc = client.post("/api/accounts", headers=H, json={
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.models.tables import Account
|
||||||
|
|
||||||
|
H = {"Authorization": "Bearer test-key"}
|
||||||
|
|
||||||
|
|
||||||
def test_pages_require_login(client):
|
def test_pages_require_login(client):
|
||||||
for path in ("/", "/buchungen", "/import", "/planung", "/hilfe"):
|
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
|
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):
|
def test_import_page_has_dropzone_and_list(client):
|
||||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
r = client.get("/import")
|
r = client.get("/import")
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
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
|
from app.services.suggestions import suggest_recurring
|
||||||
|
|
||||||
H = {"Authorization": "Bearer test-key"}
|
H = {"Authorization": "Bearer test-key"}
|
||||||
|
|
||||||
|
|
||||||
def _seed_balance(db, amount="1000.00"):
|
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")
|
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||||
db.add(acc)
|
db.add(acc)
|
||||||
db.flush()
|
db.flush()
|
||||||
db.add(Statement(filename="s.pdf", bank="dkb", account_id=acc.id,
|
acc.anchor_date = date(2026, 6, 30)
|
||||||
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
|
acc.anchor_balance = Decimal(amount)
|
||||||
opening_balance=Decimal("0"), closing_balance=Decimal(amount),
|
|
||||||
status="confirmed"))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user