338 lines
10 KiB
Python
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
|