from datetime import date import pytest from sqlalchemy.orm import Session from app.budgeting.models import PeriodClosure from app.budgeting.service import ( close_period, compute_period_state, copy_previous_month, create_category, get_or_create_period, reopen_period, set_assignments, ) from app.categorization import service as categorization from app.errors import AppError from app.identity.models import User from app.ledger.models import Category from app.ledger.service import create_account, create_expense from app.spaces.service import create_space def seed(db: Session): user = User(email="budget@example.test", password_hash="unused-in-domain-tests") db.add(user) db.flush() return user, create_space(db, user=user, name="Budget") def test_uncategorized_expense_blocks_close(db: Session) -> None: user, space = seed(db) account = create_account( db, space_id=space.id, user=user, name="Cash", kind="cash", opened_on=date(2026, 3, 1), opening_amount_kopecks=10_000, ) create_expense( db, space_id=space.id, user=user, account_id=account.id, amount_kopecks=1_000, occurred_on=date(2026, 3, 2), ) period = get_or_create_period(db, space_id=space.id, year=2026, month=3) with pytest.raises(AppError) as exc: close_period(db, space_id=space.id, user=user, period=period) assert exc.value.code == "BUDGET_CLOSE_BLOCKED" def test_default_mcc_categories_are_budgetable_and_editable_mapping(db: Session) -> None: user, space = seed(db) defaults = db.query(Category).filter_by(space_id=space.id).all() by_key = {category.system_key: category for category in defaults} assert len([key for key in by_key if key not in {None, "savings"}]) == 21 groceries = by_key["groceries"] others = by_key["others"] assert {5411, 5422}.issubset( categorization.get_category_mcc_codes(db, space_id=space.id, category_id=groceries.id) ) period = get_or_create_period(db, space_id=space.id, year=2026, month=5) set_assignments( db, space_id=space.id, user=user, period=period, items=[{"category_id": groceries.id, "planned_kopecks": 2_000}], ) account = create_account( db, space_id=space.id, user=user, name="Cash", kind="cash", opened_on=date(2026, 5, 1), ) create_expense( db, space_id=space.id, user=user, account_id=account.id, amount_kopecks=500, occurred_on=date(2026, 5, 2), category_id=others.id, ) groceries_match = categorization.category_for_mcc(db, space_id=space.id, code=5999) assert groceries_match is not None assert groceries_match.id == others.id custom = create_category(db, space_id=space.id, user=user, kind="monthly", name="My shops") codes = categorization.set_category_mcc_codes( db, space_id=space.id, category_id=custom.id, codes=[5999, 5994] ) assert codes == [5994, 5999] custom_match = categorization.category_for_mcc(db, space_id=space.id, code=5999) assert custom_match is not None assert custom_match.id == custom.id custom.archived = True fallback_match = categorization.category_for_mcc(db, space_id=space.id, code=5999) assert fallback_match is not None assert fallback_match.id == others.id custom.archived = False with pytest.raises(AppError) as exc: categorization.set_category_mcc_codes( db, space_id=space.id, category_id=custom.id, codes=[5411] ) assert exc.value.code == "CATEGORY_MCC_TAKEN" def test_copy_previous_month_clamps_expected_day(db: Session) -> None: from app.budgeting.service import add_expected_income user, space = seed(db) category = create_category(db, space_id=space.id, user=user, kind="monthly", name="Food") january = get_or_create_period(db, space_id=space.id, year=2026, month=1) february = get_or_create_period(db, space_id=space.id, year=2026, month=2) set_assignments( db, space_id=space.id, user=user, period=january, items=[{"category_id": category.id, "planned_kopecks": 30_000}], ) add_expected_income( db, space_id=space.id, user=user, period=january, expected_on=date(2026, 1, 31), amount_kopecks=100_000, source_name="Salary", ) result = copy_previous_month(db, space_id=space.id, user=user, period=february) assert result == {"assignments_copied": 1, "expected_incomes_copied": 1} state = compute_period_state(db, space_id=space.id, period=february) assert state["expected_incomes"][0]["expected_on"] == "2026-02-28" def test_reopen_preserves_immutable_closure_history(db: Session) -> None: user, space = seed(db) period = get_or_create_period(db, space_id=space.id, year=2026, month=4) close_period(db, space_id=space.id, user=user, period=period) reopen_period(db, space_id=space.id, user=user, period=period) close_period(db, space_id=space.id, user=user, period=period) assert db.query(PeriodClosure).filter_by(period_id=period.id).count() == 2 def test_periods_must_close_in_calendar_order(db: Session) -> None: user, space = seed(db) august = get_or_create_period(db, space_id=space.id, year=2026, month=8) september = get_or_create_period(db, space_id=space.id, year=2026, month=9) with pytest.raises(AppError) as exc: close_period(db, space_id=space.id, user=user, period=september) assert exc.value.code == "BUDGET_CLOSE_OUT_OF_ORDER" close_period(db, space_id=space.id, user=user, period=august) close_period(db, space_id=space.id, user=user, period=september)