from __future__ import annotations from datetime import date from decimal import Decimal import pytest from quartermaster import month_service, service from quartermaster.models import Section def _apply(db, month, entry_id, amount): """Record an applied amount against a month entry by posting.""" return month_service.add_posting( db, month, entry_id, date.today(), Decimal(str(amount)), description="test" ) def _seed_budget(db): income = service.add_entry(db, Section.income, "Paycheck", Decimal("2500.00")) rent = service.add_entry( db, Section.fixed_bill, "Rent", Decimal("1200.00") ) card_a = service.add_entry( db, Section.debt_minimum, "Card A", Decimal("40.00") ) card_b = service.add_entry( db, Section.debt_minimum, "Card B", Decimal("60.00") ) service.set_debt_target(db, card_a.id) return income, rent, card_a, card_b def test_valid_year_month(): assert month_service.valid_year_month("2026-04") assert month_service.valid_year_month("2026-12") assert not month_service.valid_year_month("2026-00") assert not month_service.valid_year_month("2026-13") assert not month_service.valid_year_month("26-04") assert not month_service.valid_year_month("2026/04") def test_shift_year_month_rolls_years(): assert month_service.shift_year_month("2026-12", 1) == "2027-01" assert month_service.shift_year_month("2026-01", -1) == "2025-12" assert month_service.shift_year_month("2026-04", 0) == "2026-04" assert month_service.shift_year_month("2026-04", 12) == "2027-04" def test_create_month_snapshots_budget(db): income, rent, card_a, card_b = _seed_budget(db) month = month_service.create_month(db, "2026-04") assert month.year_month == "2026-04" assert len(month.entries) == 4 by_source = {e.source_entry_id: e for e in month.entries} assert by_source[income.id].planned == Decimal("2500.00") assert by_source[income.id].origin_name == "Paycheck" assert by_source[income.id].origin_planned == Decimal("2500.00") assert by_source[income.id].applied == Decimal("0.00") assert by_source[rent.id].section == Section.fixed_bill assert month.target is not None assert month.target.entry is not None assert month.target.entry.source_entry_id == card_a.id def test_create_month_idempotent(db): _seed_budget(db) first = month_service.create_month(db, "2026-04") second = month_service.create_month(db, "2026-04") assert first.id == second.id def test_create_month_rejects_bad_year_month(db): with pytest.raises(ValueError): month_service.create_month(db, "2026-00") def test_deviation_state_reflects_edits(db): _seed_budget(db) month = month_service.create_month(db, "2026-04") rent = next(e for e in month.entries if e.origin_name == "Rent") assert month_service.deviation_state(rent) == month_service.DeviationState.unchanged month_service.update_month_entry( db, month, rent.id, planned=Decimal("1300.00") ) rent = next(e for e in month.entries if e.id == rent.id) assert month_service.deviation_state(rent) == month_service.DeviationState.edited def test_added_row_is_new_in_month(db): _seed_budget(db) month = month_service.create_month(db, "2026-04") extra = month_service.add_month_entry( db, month, Section.other, "Surprise", Decimal("20.00") ) assert extra.origin_name is None assert month_service.deviation_state(extra) == ( month_service.DeviationState.new_in_month ) def test_applied_update_changes_totals(db): _seed_budget(db) month = month_service.create_month(db, "2026-04") rent = next(e for e in month.entries if e.origin_name == "Rent") _apply(db, month, rent.id, "1200.00") db.refresh(month) view = month_service.section_view(month, Section.fixed_bill, "Fixed") assert view.total_applied == Decimal("1200.00") assert view.total_planned == Decimal("1200.00") def test_per_month_target_is_isolated(db): _seed_budget(db) april = month_service.create_month(db, "2026-04") may = month_service.create_month(db, "2026-05") april_b = next( e for e in april.entries if e.section == Section.debt_minimum and e.origin_name == "Card B" ) month_service.set_month_target(db, april, april_b.id) april_target = month_service.get_month_target(db, april) may_target = month_service.get_month_target(db, may) assert april_target.entry is not None assert april_target.entry.origin_name == "Card B" assert may_target.entry is not None assert may_target.entry.origin_name == "Card A" def test_delete_month_entry_returns_section(db): _seed_budget(db) month = month_service.create_month(db, "2026-04") rent = next(e for e in month.entries if e.origin_name == "Rent") result = month_service.delete_month_entry(db, month, rent.id) assert result == Section.fixed_bill db.refresh(month) remaining = [ e for e in month.entries if e.section == Section.fixed_bill ] assert remaining == [] def test_budget_entry_deleted_after_snapshot_leaves_month_intact(db): _seed_budget(db) month = month_service.create_month(db, "2026-04") # remove budget-side rent; month-side rent should still render unchanged budget_rent = service.list_entries(db, Section.fixed_bill)[0] service.delete_entry(db, budget_rent.id) db.expire_all() month_rent = next(e for e in month.entries if e.origin_name == "Rent") assert month_rent.source_entry_id is None assert month_service.deviation_state(month_rent) == ( month_service.DeviationState.unchanged )