From eb7e47bcbec205838673ba94c42d0e507aaa9e7c Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 13:03:53 -0600 Subject: [PATCH 1/4] feat(db): add MonthState enum and lifecycle columns state defaults to 'planning' (server default plus SQLAlchemy default). activated_at and closed_at are nullable timestamps that record when the month crossed each boundary. Alembic batch_alter_table handles the SQLite rewrite. MonthState is a Python string enum mapped to a non-native VARCHAR(16). Refs #15 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../a4ec4f8f6e9f_add_month_lifecycle_state.py | 40 +++++++++++++++++++ src/quartermaster/models.py | 18 +++++++++ 2 files changed, 58 insertions(+) create mode 100644 alembic/versions/a4ec4f8f6e9f_add_month_lifecycle_state.py 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 ) -- 2.45.2 From fa9a397d833ff08f8707b3b026b423fc86234e9e Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 13:03:57 -0600 Subject: [PATCH 2/4] feat(lifecycle): activate, close, reopen transitions with validation activate_month moves Planning to Active and stamps activated_at. close_month moves Active to Closed only when applied zero equals exactly $0.00; otherwise raises MonthLifecycleError with a message naming the current balance. reopen_month moves Closed back to Active and nulls closed_at. ensure_editable is the guard mutation routes call before any write. No automatic sweep: filling the target row is the user's job via editing applied amounts. Refs #15 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/month_service.py | 54 +++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) 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") -- 2.45.2 From 1df3c1c2188020773e4f624c930e371fd6ba4cc6 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 13:04:03 -0600 Subject: [PATCH 3/4] feat(lifecycle): transition routes, state badge, and edit-locking UI POST /month/{ym}/activate, /close, /reopen each return 204 with HX-Redirect so the page re-renders in the new state. All existing mutation routes now go through _require_editable_month, which 400s on closed months. Month nav grows a state badge and a context-appropriate lifecycle button. Close is rendered with a disabled attribute and tooltip when applied zero != 0. On closed months, name / planned / applied / notes inputs carry the disabled attribute; delete buttons, add forms, and the target form are omitted entirely. Refs #15 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/quartermaster/routes_month.py | 88 +++++++++++++++-- src/quartermaster/static/app.css | 60 ++++++++++++ .../templates/partials/month_nav.html | 26 +++++ .../templates/partials/month_section.html | 94 +++++++++++-------- .../templates/partials/month_target.html | 44 ++++----- 5 files changed, 243 insertions(+), 69 deletions(-) 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 %} -- 2.45.2 From 8fec2fdff78ffbcec80670918cca1a5a03a750c5 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 13:04:06 -0600 Subject: [PATCH 4/4] test: cover lifecycle transitions, balance gate, and edit-locking Service tests walk Planning -> Active -> Closed -> Active and confirm rejects on out-of-order transitions. Close rejects when applied zero is nonzero; succeeds when balanced; reopens cleanly. Route tests confirm each endpoint's status codes, HX-Redirect headers, and that the page renders the right badge and button per state. Closed months reject every mutation with 400 and their rendered HTML carries disabled inputs without add forms or delete buttons. Refs #15 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_month_lifecycle.py | 243 ++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tests/test_month_lifecycle.py 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 -- 2.45.2