diff --git a/alembic/versions/a4ec4f8f6e9f_add_month_lifecycle_state.py b/alembic/versions/a4ec4f8f6e9f_add_month_lifecycle_state.py new file mode 100644 index 0000000..fd89b7c --- /dev/null +++ b/alembic/versions/a4ec4f8f6e9f_add_month_lifecycle_state.py @@ -0,0 +1,40 @@ +"""add month lifecycle state + +Revision ID: a4ec4f8f6e9f +Revises: ec804bdf366d +Create Date: 2026-04-17 12:59:25.811354 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a4ec4f8f6e9f' +down_revision: Union[str, Sequence[str], None] = 'ec804bdf366d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('month', schema=None) as batch_op: + batch_op.add_column(sa.Column('state', sa.Enum('planning', 'active', 'closed', name='monthstate', native_enum=False, length=16), server_default='planning', nullable=False)) + batch_op.add_column(sa.Column('activated_at', sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column('closed_at', sa.DateTime(timezone=True), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('month', schema=None) as batch_op: + batch_op.drop_column('closed_at') + batch_op.drop_column('activated_at') + batch_op.drop_column('state') + + # ### end Alembic commands ### diff --git a/src/quartermaster/models.py b/src/quartermaster/models.py index c2edacb..6d8f25a 100644 --- a/src/quartermaster/models.py +++ b/src/quartermaster/models.py @@ -27,6 +27,12 @@ class Section(str, enum.Enum): sinking_fund = "sinking_fund" +class MonthState(str, enum.Enum): + planning = "planning" + active = "active" + closed = "closed" + + SECTION_LABELS: dict[Section, str] = { Section.income: "Incomes", Section.fixed_bill: "Fixed Amount Bills", @@ -86,6 +92,18 @@ class Month(Base): id: Mapped[int] = mapped_column(primary_key=True) year_month: Mapped[str] = mapped_column(String(7), nullable=False, unique=True) + state: Mapped[MonthState] = mapped_column( + Enum(MonthState, native_enum=False, length=16), + nullable=False, + default=MonthState.planning, + server_default=MonthState.planning.value, + ) + activated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + closed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py index 8928aad..9ecc0b9 100644 --- a/src/quartermaster/month_service.py +++ b/src/quartermaster/month_service.py @@ -2,7 +2,7 @@ from __future__ import annotations import re from dataclasses import dataclass -from datetime import date +from datetime import date, datetime, timezone from decimal import Decimal from enum import Enum @@ -23,6 +23,7 @@ from quartermaster.models import ( Month, MonthDebtTarget, MonthEntry, + MonthState, Section, ) @@ -306,3 +307,54 @@ def set_month_target( db.commit() db.refresh(target) return target + + +class MonthLifecycleError(Exception): + """Raised when a state transition or edit is rejected.""" + + +def activate_month(db: Session, month: Month) -> Month: + if month.state != MonthState.planning: + raise MonthLifecycleError( + f"cannot activate a {month.state.value} month" + ) + month.state = MonthState.active + month.activated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(month) + return month + + +def close_month(db: Session, month: Month) -> Month: + if month.state != MonthState.active: + raise MonthLifecycleError( + f"cannot close a {month.state.value} month" + ) + zero = month_zero(month) + if zero.applied != Decimal("0.00"): + raise MonthLifecycleError( + "applied balance must equal $0.00 before closing; " + f"currently ${zero.applied}" + ) + month.state = MonthState.closed + month.closed_at = datetime.now(timezone.utc) + db.commit() + db.refresh(month) + return month + + +def reopen_month(db: Session, month: Month) -> Month: + if month.state != MonthState.closed: + raise MonthLifecycleError( + f"cannot reopen a {month.state.value} month" + ) + month.state = MonthState.active + month.closed_at = None + db.commit() + db.refresh(month) + return month + + +def ensure_editable(month: Month) -> None: + if month.state == MonthState.closed: + raise MonthLifecycleError("month is closed; reopen to edit") diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py index b6ee5e2..729dee6 100644 --- a/src/quartermaster/routes_month.py +++ b/src/quartermaster/routes_month.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from quartermaster import month_service, service from quartermaster.db import get_session -from quartermaster.models import SECTION_LABELS, Month, Section +from quartermaster.models import SECTION_LABELS, Month, MonthState, Section from quartermaster.routes import templates router = APIRouter(prefix="/month/{year_month}", tags=["month"]) @@ -42,6 +42,15 @@ def _require_month(db: Session, year_month: str) -> Month: return month +def _require_editable_month(db: Session, year_month: str) -> Month: + month = _require_month(db, year_month) + try: + month_service.ensure_editable(month) + except month_service.MonthLifecycleError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return month + + def _section_views(month: Month) -> list[month_service.MonthSectionView]: return [ month_service.section_view(month, s, SECTION_LABELS[s]) for s in Section @@ -55,7 +64,11 @@ def _render_section( return templates.TemplateResponse( request, "partials/month_section.html", - {"month": month, "section": view}, + { + "month": month, + "section": view, + "editable": month.state != MonthState.closed, + }, ) @@ -66,7 +79,12 @@ def _render_target(request: Request, db: Session, month: Month) -> HTMLResponse: return templates.TemplateResponse( request, "partials/month_target.html", - {"month": month, "target": target, "debt_minimums": debt_minimums}, + { + "month": month, + "target": target, + "debt_minimums": debt_minimums, + "editable": month.state != MonthState.closed, + }, ) @@ -119,6 +137,11 @@ def view_month( }, ) zero = month_service.month_zero(month) + editable = month.state != MonthState.closed + can_close = ( + month.state == MonthState.active + and zero.applied == Decimal("0.00") + ) return templates.TemplateResponse( request, "month.html", @@ -137,6 +160,9 @@ def view_month( "zero": zero, "planned_tone": service.zero_tone(zero.planned), "applied_tone": service.zero_tone(zero.applied), + "state": month.state.value, + "editable": editable, + "can_close": can_close, }, ) @@ -167,7 +193,7 @@ def add_month_entry( notes: str | None = Form(None), db: Session = Depends(get_session), ) -> HTMLResponse: - month = _require_month(db, year_month) + month = _require_editable_month(db, year_month) clean_name = name.strip() if not clean_name: raise HTTPException(status_code=400, detail="name is required") @@ -189,7 +215,7 @@ def delete_month_entry( request: Request, db: Session = Depends(get_session), ) -> HTMLResponse: - month = _require_month(db, year_month) + month = _require_editable_month(db, year_month) section = month_service.delete_month_entry(db, month, entry_id) if section is None: raise HTTPException(status_code=404, detail="entry not found") @@ -214,7 +240,7 @@ def update_month_entry( notes: str | None = Form(None), db: Session = Depends(get_session), ) -> HTMLResponse: - month = _require_month(db, year_month) + month = _require_editable_month(db, year_month) clean_name: str | None = None if name is not None: clean_name = name.strip() @@ -240,6 +266,54 @@ def update_month_entry( ) +@router.post("/activate") +def activate_month( + year_month: str, + db: Session = Depends(get_session), +) -> Response: + month = _require_month(db, year_month) + try: + month_service.activate_month(db, month) + except month_service.MonthLifecycleError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return Response( + status_code=204, + headers={"HX-Redirect": f"/month/{year_month}"}, + ) + + +@router.post("/close") +def close_month( + year_month: str, + db: Session = Depends(get_session), +) -> Response: + month = _require_month(db, year_month) + try: + month_service.close_month(db, month) + except month_service.MonthLifecycleError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return Response( + status_code=204, + headers={"HX-Redirect": f"/month/{year_month}"}, + ) + + +@router.post("/reopen") +def reopen_month( + year_month: str, + db: Session = Depends(get_session), +) -> Response: + month = _require_month(db, year_month) + try: + month_service.reopen_month(db, month) + except month_service.MonthLifecycleError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return Response( + status_code=204, + headers={"HX-Redirect": f"/month/{year_month}"}, + ) + + @router.post("/target", response_class=HTMLResponse) def update_month_target( year_month: str, @@ -247,7 +321,7 @@ def update_month_target( month_entry_id: str = Form(""), db: Session = Depends(get_session), ) -> HTMLResponse: - month = _require_month(db, year_month) + month = _require_editable_month(db, year_month) raw = month_entry_id.strip() target_id: int | None if raw == "": diff --git a/src/quartermaster/static/app.css b/src/quartermaster/static/app.css index 5106d1e..c8cdf8d 100644 --- a/src/quartermaster/static/app.css +++ b/src/quartermaster/static/app.css @@ -336,6 +336,66 @@ details.group > .section:last-child { background: #f0ece0; } +.month-nav .state-badge { + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.state-badge.state-planning { + background: #e9ecf5; + color: #3a4fa1; +} + +.state-badge.state-active { + background: #d9f0e4; + color: #1f6b44; +} + +.state-badge.state-closed { + background: #e8e6dd; + color: #4a4a40; + text-transform: none; + letter-spacing: 0; +} + +.month-nav .lifecycle-form { + margin: 0; +} + +.month-nav .lifecycle-form button { + padding: 0.35rem 0.9rem; + font-size: 0.9rem; + border: 1px solid var(--rule); + border-radius: 3px; + cursor: pointer; + font: inherit; + background: var(--accent); + color: #fff; +} + +.month-nav .lifecycle-form button.secondary { + background: #fff; + color: var(--accent); +} + +.month-nav .lifecycle-form button[disabled] { + opacity: 0.5; + cursor: not-allowed; + background: #c5d4ce; + color: #fff; +} + +input[disabled], +select[disabled] { + background: #f4f3ef; + color: #6b6b6b; + cursor: not-allowed; +} + .month-nav .spacer { flex: 1; } diff --git a/src/quartermaster/templates/partials/month_nav.html b/src/quartermaster/templates/partials/month_nav.html index 3d3c636..bb60abc 100644 --- a/src/quartermaster/templates/partials/month_nav.html +++ b/src/quartermaster/templates/partials/month_nav.html @@ -2,6 +2,32 @@ ← {{ prev_year_month }} {{ year_month }} {{ next_year_month }} → + {% if state %} + + {% if state == 'closed' and month.closed_at %} + Closed {{ month.closed_at.strftime('%Y-%m-%d') }} + {% else %} + {{ state | capitalize }} + {% endif %} + + {% if state == 'planning' %} +
+ +
+ {% elif state == 'active' %} +
+ +
+ {% elif state == 'closed' %} +
+ +
+ {% endif %} + {% endif %} {% if all_months %} - - - - - - + {% if editable %} + + +
+ + + + +
+ + + {% endif %} diff --git a/src/quartermaster/templates/partials/month_target.html b/src/quartermaster/templates/partials/month_target.html index 5f0b73a..ca36f64 100644 --- a/src/quartermaster/templates/partials/month_target.html +++ b/src/quartermaster/templates/partials/month_target.html @@ -20,27 +20,29 @@ - - -
- - -
- - + {% if editable %} + + +
+ + +
+ + + {% endif %} diff --git a/tests/test_month_lifecycle.py b/tests/test_month_lifecycle.py new file mode 100644 index 0000000..f75ee66 --- /dev/null +++ b/tests/test_month_lifecycle.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from quartermaster import month_service, service +from quartermaster.models import MonthState, Section + + +def _seed(db, balance_to_zero=False): + service.add_entry(db, Section.income, "Paycheck", Decimal("1000.00")) + service.add_entry(db, Section.fixed_bill, "Rent", Decimal("600.00")) + service.add_entry(db, Section.debt_minimum, "Card A", Decimal("40.00")) + service.add_entry(db, Section.food, "Groceries", Decimal("300.00")) + service.add_entry(db, Section.other, "Misc", Decimal("60.00")) + month = month_service.create_month(db, "2026-04") + if balance_to_zero: + # Apply income fully and distribute applied to match planned exactly + for entry in month.entries: + month_service.update_month_entry( + db, month, entry.id, applied=entry.planned + ) + db.refresh(month) + return month + + +def test_create_month_defaults_to_planning(db): + _seed(db) + month = month_service.get_month(db, "2026-04") + assert month is not None + assert month.state == MonthState.planning + assert month.activated_at is None + assert month.closed_at is None + + +def test_activate_transitions_to_active(db): + month = _seed(db) + activated = month_service.activate_month(db, month) + assert activated.state == MonthState.active + assert activated.activated_at is not None + + +def test_activate_rejects_non_planning_month(db): + month = _seed(db) + month_service.activate_month(db, month) + with pytest.raises(month_service.MonthLifecycleError): + month_service.activate_month(db, month) + + +def test_close_requires_active(db): + month = _seed(db) + # still planning; close should reject + with pytest.raises(month_service.MonthLifecycleError): + month_service.close_month(db, month) + + +def test_close_requires_zero_balance(db): + month = _seed(db) + month_service.activate_month(db, month) + # applied zero is 0 - 0 = 0, but income applied is 0 so + # (0 income - 0 others) == 0 actually. Force a non-zero by applying to food. + food = next(e for e in month.entries if e.origin_name == "Groceries") + month_service.update_month_entry( + db, month, food.id, applied=Decimal("50.00") + ) + db.refresh(month) + with pytest.raises(month_service.MonthLifecycleError) as exc: + month_service.close_month(db, month) + assert "0.00" in str(exc.value) + + +def test_close_succeeds_when_balanced(db): + month = _seed(db, balance_to_zero=True) + month_service.activate_month(db, month) + closed = month_service.close_month(db, month) + assert closed.state == MonthState.closed + assert closed.closed_at is not None + + +def test_reopen_from_closed(db): + month = _seed(db, balance_to_zero=True) + month_service.activate_month(db, month) + month_service.close_month(db, month) + reopened = month_service.reopen_month(db, month) + assert reopened.state == MonthState.active + assert reopened.closed_at is None + + +def test_reopen_rejects_non_closed(db): + month = _seed(db) + with pytest.raises(month_service.MonthLifecycleError): + month_service.reopen_month(db, month) + + +def test_ensure_editable_rejects_closed(db): + month = _seed(db, balance_to_zero=True) + month_service.activate_month(db, month) + month_service.close_month(db, month) + with pytest.raises(month_service.MonthLifecycleError): + month_service.ensure_editable(month) + + +def test_ensure_editable_allows_planning_and_active(db): + month = _seed(db) + month_service.ensure_editable(month) # planning: fine + month_service.activate_month(db, month) + month_service.ensure_editable(month) # active: fine + + +# --- route-level ----------------------------------------------------------- + + +def _seed_via_api(client, balance_to_zero=False): + client.post( + "/sections/income/entries", + data={"name": "Paycheck", "amount": "1000.00"}, + ) + client.post( + "/sections/fixed_bill/entries", + data={"name": "Rent", "amount": "600.00"}, + ) + client.post( + "/sections/food/entries", + data={"name": "Groceries", "amount": "400.00"}, + ) + client.post("/month/2026-04/create") + if balance_to_zero: + # entry ids 1,2,3 in that order + client.post( + "/month/2026-04/entries/1", data={"applied": "1000.00"} + ) + client.post( + "/month/2026-04/entries/2", data={"applied": "600.00"} + ) + client.post( + "/month/2026-04/entries/3", data={"applied": "400.00"} + ) + + +def test_activate_route(client): + _seed_via_api(client) + response = client.post("/month/2026-04/activate") + assert response.status_code == 204 + assert response.headers.get("hx-redirect") == "/month/2026-04" + + +def test_close_route_rejects_unbalanced(client): + _seed_via_api(client) + client.post("/month/2026-04/activate") + # apply some expense without income to produce an unbalanced state + client.post("/month/2026-04/entries/2", data={"applied": "100.00"}) + response = client.post("/month/2026-04/close") + assert response.status_code == 400 + assert "0.00" in response.json()["detail"] + + +def test_close_route_succeeds_balanced(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + response = client.post("/month/2026-04/close") + assert response.status_code == 204 + assert response.headers.get("hx-redirect") == "/month/2026-04" + + +def test_reopen_route(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + client.post("/month/2026-04/close") + response = client.post("/month/2026-04/reopen") + assert response.status_code == 204 + + +def test_mutations_rejected_on_closed_month(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + client.post("/month/2026-04/close") + # try to add, update, delete, and re-set target + add_response = client.post( + "/month/2026-04/sections/other/entries", + data={"name": "Nope", "planned": "10.00"}, + ) + assert add_response.status_code == 400 + update_response = client.post( + "/month/2026-04/entries/1", data={"applied": "1200.00"} + ) + assert update_response.status_code == 400 + delete_response = client.delete("/month/2026-04/entries/1") + assert delete_response.status_code == 400 + + +def test_month_page_shows_planning_badge_and_activate_button(client): + _seed_via_api(client) + response = client.get("/month/2026-04") + assert response.status_code == 200 + assert "state-planning" in response.text + assert "/activate" in response.text + assert "Activate" in response.text + + +def test_month_page_shows_active_badge_with_disabled_close_when_unbalanced(client): + _seed_via_api(client) + client.post("/month/2026-04/activate") + # force imbalance + client.post("/month/2026-04/entries/2", data={"applied": "100.00"}) + response = client.get("/month/2026-04") + assert response.status_code == 200 + assert "state-active" in response.text + assert "/close" in response.text + assert "disabled" in response.text + + +def test_month_page_shows_active_badge_with_enabled_close_when_balanced(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + response = client.get("/month/2026-04") + assert "state-active" in response.text + assert "/close" in response.text + # Close button is present without the disabled attribute on the button itself + assert 'type="submit"' in response.text + + +def test_month_page_shows_closed_badge_and_reopen_button(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + client.post("/month/2026-04/close") + response = client.get("/month/2026-04") + assert "state-closed" in response.text + assert "/reopen" in response.text + assert "Closed" in response.text + # add forms hidden on closed month + assert "hx-post=\"/month/2026-04/sections/" not in response.text + + +def test_closed_month_inputs_are_disabled(client): + _seed_via_api(client, balance_to_zero=True) + client.post("/month/2026-04/activate") + client.post("/month/2026-04/close") + response = client.get("/month/2026-04") + # name inputs rendered with disabled + assert "disabled" in response.text + # delete buttons not rendered + assert "Delete Paycheck" not in response.text