Files
finance/backend/tests/test_budget_savings.py
T

338 lines
10 KiB
Python

"""Budget semantics for savings transfers, adjustments and archived history."""
from datetime import date
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from app.budgeting.service import (
compute_period_state,
create_category,
fund,
get_or_create_period,
set_assignments,
)
from app.errors import AppError
from app.identity.models import User
from app.ledger.savings import adjust_balance, ensure_savings_resources
from app.ledger.service import create_account, create_expense, create_income, create_transfer
from app.spaces.service import create_space
def seed(db: Session):
user = User(email=f"{uuid4().hex}@example.com", password_hash="unused")
db.add(user)
db.flush()
space = create_space(db, user=user, name="BudgetSavings")
account = create_account(
db,
space_id=space.id,
user=user,
name="Debit",
kind="debit",
opened_on=date(2026, 5, 1),
opening_amount_kopecks=1_000_000,
)
return user, space, account
def savings_row(state: dict) -> dict:
return next(row for row in state["envelopes"] if row["name_key"] == "savings")
def test_transfer_to_savings_moves_pool_into_envelope(db: Session) -> None:
user, space, account = seed(db)
savings_account, _ = ensure_savings_resources(db, space_id=space.id)
before = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
assert before["pool"] == 0
assert savings_row(before)["available"] == 0
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=250_000,
occurred_on=date(2026, 5, 10),
)
after = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
row = savings_row(after)
assert after["pool"] == -250_000
assert row["funded"] == 250_000
assert row["spent"] == 0
assert row["available"] == 250_000
assert after["income_total"] == 0
def test_transfer_back_from_savings_returns_pool(db: Session) -> None:
user, space, account = seed(db)
savings_account, _ = ensure_savings_resources(db, space_id=space.id)
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=250_000,
occurred_on=date(2026, 5, 10),
)
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=savings_account.id,
target_account_id=account.id,
amount_kopecks=100_000,
occurred_on=date(2026, 5, 12),
)
state = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
row = savings_row(state)
assert state["pool"] == -150_000
assert row["funded"] == 150_000
assert row["available"] == 150_000
def test_adjustment_changes_only_envelope_available_not_pool(db: Session) -> None:
user, space, account = seed(db)
savings_account, _ = ensure_savings_resources(db, space_id=space.id)
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=100_000,
occurred_on=date(2026, 5, 3),
)
adjust_balance(
db,
space_id=space.id,
user=user,
account_id=savings_account.id,
target_balance_kopecks=80_000,
occurred_on=date(2026, 5, 20),
reason="Процентная корректировка",
)
state = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
row = savings_row(state)
assert row["available"] == 80_000
assert row["funded"] == 100_000
assert state["income_total"] == 0
assert state["pool"] == -100_000
def test_adjustment_on_regular_account_keeps_pool_and_totals(db: Session) -> None:
user, space, account = seed(db)
create_income(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=300_000,
occurred_on=date(2026, 5, 2),
)
adjust_balance(
db,
space_id=space.id,
user=user,
account_id=account.id,
target_balance_kopecks=1_000_000,
occurred_on=date(2026, 5, 21),
reason="Списание банка не отразилось",
)
state = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
assert state["income_total"] == 300_000
# The adjustment reconciles the account but never enters the budget pool.
assert state["pool"] == 300_000
assert state["uncategorized_expenses"] == 0
assert state["monthly_categories"] == []
def test_savings_envelope_available_uses_month_end_balance(db: Session) -> None:
user, space, account = seed(db)
savings_account, _ = ensure_savings_resources(db, space_id=space.id)
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=50_000,
occurred_on=date(2026, 5, 28),
)
# A transfer in a later month must not affect the May envelope view.
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=70_000,
occurred_on=date(2026, 6, 2),
)
may = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
june = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=6),
)
assert savings_row(may)["available"] == 50_000
assert savings_row(may)["funded"] == 50_000
assert savings_row(june)["available"] == 120_000
assert savings_row(june)["funded"] == 70_000
def test_archived_category_history_stays_in_totals(db: Session) -> None:
user, space, account = seed(db)
category = create_category(db, space_id=space.id, user=user, kind="monthly", name="Transport")
set_assignments(
db,
space_id=space.id,
user=user,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
items=[{"category_id": category.id, "planned_kopecks": 20_000}],
)
create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=5_000,
occurred_on=date(2026, 5, 6),
category_id=category.id,
)
category.archived = True
db.flush()
state = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
rows = [row for row in state["monthly_categories"] if row["category_id"] == str(category.id)]
assert len(rows) == 1
row = rows[0]
assert row["archived"] is True
assert row["spent"] == 5_000
assert row["planned"] == 20_000
# An archived category refuses new assignments...
try:
set_assignments(
db,
space_id=space.id,
user=user,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
items=[{"category_id": category.id, "planned_kopecks": 1}],
)
raise AssertionError("archived assignment must fail")
except AppError as exc:
assert exc.code == "BUDGET_CATEGORY_ARCHIVED"
# ...but its history still counts in the funding/spent totals.
summary_funded = sum(row["funded"] for row in state["monthly_categories"])
assert summary_funded == 0
def test_system_category_rejects_planning_funding_and_regular_expense(db: Session) -> None:
user, space, account = seed(db)
_, savings_category = ensure_savings_resources(db, space_id=space.id)
period = get_or_create_period(db, space_id=space.id, year=2026, month=5)
with pytest.raises(AppError) as exc:
set_assignments(
db,
space_id=space.id,
user=user,
period=period,
items=[{"category_id": savings_category.id, "planned_kopecks": 10_000}],
)
assert exc.value.code == "BUDGET_CATEGORY_SYSTEM"
with pytest.raises(AppError) as exc:
fund(
db,
space_id=space.id,
user=user,
period=period,
category_id=savings_category.id,
amount_kopecks=10_000,
kind="fund",
)
assert exc.value.code == "BUDGET_CATEGORY_SYSTEM"
with pytest.raises(AppError) as exc:
create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=10_000,
occurred_on=date(2026, 5, 10),
category_id=savings_category.id,
)
assert exc.value.code == "BUDGET_CATEGORY_SYSTEM"
def test_archived_category_rejects_new_funding(db: Session) -> None:
user, space, _ = seed(db)
category = create_category(db, space_id=space.id, user=user, kind="monthly", name="Old")
category.archived = True
period = get_or_create_period(db, space_id=space.id, year=2026, month=5)
with pytest.raises(AppError) as exc:
fund(
db,
space_id=space.id,
user=user,
period=period,
category_id=category.id,
amount_kopecks=1_000,
kind="fund",
)
assert exc.value.code == "BUDGET_CATEGORY_ARCHIVED"
def test_blockers_carry_discriminated_data(db: Session) -> None:
user, space, account = seed(db)
create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=5_000,
occurred_on=date(2026, 5, 6),
)
state = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
by_code = {blocker["code"]: blocker for blocker in state["close_blockers"]}
assert by_code["BUDGET_UNCATEGORIZED_EXPENSES"]["count"] == 1
assert "IMPORTS_UNRESOLVED" not in by_code
assert "IMPORT_UNRESOLVED_HOLDS" not in by_code