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.
156 lines
5.5 KiB
Python
156 lines
5.5 KiB
Python
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,
|
|
)
|
|
assert categorization.category_for_mcc(db, space_id=space.id, code=5999).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]
|
|
assert categorization.category_for_mcc(db, space_id=space.id, code=5999).id == custom.id
|
|
custom.archived = True
|
|
assert categorization.category_for_mcc(db, space_id=space.id, code=5999).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)
|