from __future__ import annotations from datetime import date from decimal import Decimal import pytest from quartermaster import month_service, service from quartermaster.models import Posting, Section def _seed_month(db, section=Section.fixed_bill, name="Rent", planned="1200"): service.add_entry(db, Section.income, "Paycheck", Decimal("2500")) service.add_entry(db, section, name, Decimal(planned)) return month_service.create_month(db, "2026-04") def test_add_posting_sums_into_applied(db): month = _seed_month(db) rent = next(e for e in month.entries if e.origin_name == "Rent") month_service.add_posting( db, month, rent.id, date(2026, 4, 1), Decimal("600"), description="first half" ) month_service.add_posting( db, month, rent.id, date(2026, 4, 15), Decimal("600"), description="second half" ) db.refresh(rent) assert rent.applied == Decimal("1200.00") assert len(rent.postings) == 2 def test_empty_entry_applied_is_zero(db): month = _seed_month(db) rent = next(e for e in month.entries if e.origin_name == "Rent") assert rent.applied == Decimal("0.00") assert rent.postings == [] def test_negative_posting_allowed(db): month = _seed_month(db) rent = next(e for e in month.entries if e.origin_name == "Rent") month_service.add_posting( db, month, rent.id, date(2026, 4, 1), Decimal("1200") ) month_service.add_posting( db, month, rent.id, date(2026, 4, 3), Decimal("-50"), description="refund" ) db.refresh(rent) assert rent.applied == Decimal("1150.00") def test_update_posting(db): month = _seed_month(db) rent = next(e for e in month.entries if e.origin_name == "Rent") posting = month_service.add_posting( db, month, rent.id, date(2026, 4, 1), Decimal("1100") ) month_service.update_posting( db, month, posting.id, amount=Decimal("1200"), description="corrected", occurred_on=date(2026, 4, 3), ) db.refresh(rent) assert rent.applied == Decimal("1200.00") updated = rent.postings[0] assert updated.description == "corrected" assert updated.occurred_on == date(2026, 4, 3) def test_delete_posting(db): month = _seed_month(db) rent = next(e for e in month.entries if e.origin_name == "Rent") p1 = month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) p2 = month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) entry = month_service.delete_posting(db, month, p1.id) db.refresh(rent) assert entry.id == rent.id assert rent.applied == Decimal("600.00") assert len(rent.postings) == 1 assert rent.postings[0].id == p2.id def test_delete_entry_cascades_postings(db): month = _seed_month(db) rent = next(e for e in month.entries if e.origin_name == "Rent") month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) month_service.add_posting(db, month, rent.id, date.today(), Decimal("600")) # Postings count before deletion all_postings = db.query(Posting).filter(Posting.month_entry_id == rent.id).count() assert all_postings == 2 month_service.delete_month_entry(db, month, rent.id) db.expire_all() remaining = db.query(Posting).filter(Posting.month_entry_id == rent.id).count() assert remaining == 0 def test_postings_ordered_desc(db): month = _seed_month(db) rent = next(e for e in month.entries if e.origin_name == "Rent") month_service.add_posting(db, month, rent.id, date(2026, 4, 1), Decimal("100")) month_service.add_posting(db, month, rent.id, date(2026, 4, 15), Decimal("200")) month_service.add_posting(db, month, rent.id, date(2026, 4, 8), Decimal("150")) db.refresh(rent) dates = [p.occurred_on for p in rent.postings] assert dates == [date(2026, 4, 15), date(2026, 4, 8), date(2026, 4, 1)] def test_update_month_entry_rejects_applied_kwarg(db): month = _seed_month(db) rent = next(e for e in month.entries if e.origin_name == "Rent") with pytest.raises(TypeError): month_service.update_month_entry( db, month, rent.id, applied=Decimal("500") ) # ----- Route-level tests ----- def _seed_via_api(client): client.post( "/sections/income/entries", data={"name": "Paycheck", "amount": "2500"}, ) client.post( "/sections/fixed_bill/entries", data={"name": "Rent", "amount": "1200"}, ) client.post("/month/2026-04/create") def test_add_posting_route(client): _seed_via_api(client) response = client.post( "/month/2026-04/entries/2/postings", data={ "occurred_on": "2026-04-01", "amount": "1200", "description": "April rent", "payee": "Landlord", }, ) assert response.status_code == 200 assert "April rent" in response.text assert "Landlord" in response.text def test_update_posting_route(client): _seed_via_api(client) client.post( "/month/2026-04/entries/2/postings", data={"occurred_on": "2026-04-01", "amount": "1100"}, ) # posting id is 1 (first created) response = client.post( "/month/2026-04/postings/1", data={"amount": "1200", "description": "fixed amount"}, ) assert response.status_code == 200 assert "fixed amount" in response.text def test_delete_posting_route(client): _seed_via_api(client) client.post( "/month/2026-04/entries/2/postings", data={"occurred_on": "2026-04-01", "amount": "1200"}, ) response = client.delete("/month/2026-04/postings/1") assert response.status_code == 200 # The section partial should render with no postings and applied back to $0 assert "$0.00" in response.text def test_posting_routes_reject_invalid_date(client): _seed_via_api(client) response = client.post( "/month/2026-04/entries/2/postings", data={"occurred_on": "not-a-date", "amount": "100"}, ) assert response.status_code == 400 def test_posting_rejected_on_closed_month(client): _seed_via_api(client) # Post balancing transactions so the month can close client.post( "/month/2026-04/entries/1/postings", data={"occurred_on": "2026-04-01", "amount": "1200"}, ) client.post( "/month/2026-04/entries/2/postings", data={"occurred_on": "2026-04-01", "amount": "1200"}, ) client.post("/month/2026-04/activate") close = client.post("/month/2026-04/close") assert close.status_code == 204 # Now try to add a new posting on the closed month response = client.post( "/month/2026-04/entries/1/postings", data={"occurred_on": "2026-04-20", "amount": "1"}, ) assert response.status_code == 400 def test_applied_update_via_posting_flips_zero_tone(client): _seed_via_api(client) response = client.post( "/month/2026-04/entries/2/postings", data={"occurred_on": "2026-04-01", "amount": "1200"}, ) assert response.status_code == 200 # With only rent applied, applied zero = 0 - 1200 = -1200 -> negative assert "tone-negative" in response.text def test_posting_count_badge_renders(client): _seed_via_api(client) for i in range(3): client.post( "/month/2026-04/entries/2/postings", data={"occurred_on": "2026-04-01", "amount": "400"}, ) page = client.get("/month/2026-04") assert page.status_code == 200 assert "3 txns" in page.text