63d065011b
- Updated timestamps and AST hashes in manifest.json for various backend and frontend files to reflect recent changes. - Added new files related to MCC categories and optional hold reasons in the manifest. - Modified the README in the infra directory to correct the linting tool from 'mypy' to 'ty' for type checking in the backend project.
339 lines
11 KiB
Python
339 lines
11 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 len(state["monthly_categories"]) == 21
|
|
assert all(row["spent"] == 0 for row in 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
|