Compare commits

...

4 commits

Author SHA1 Message Date
archeious
8fec2fdff7 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) <noreply@anthropic.com>
2026-04-17 13:04:06 -06:00
archeious
1df3c1c218 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) <noreply@anthropic.com>
2026-04-17 13:04:03 -06:00
archeious
fa9a397d83 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) <noreply@anthropic.com>
2026-04-17 13:03:57 -06:00
archeious
eb7e47bcbe 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) <noreply@anthropic.com>
2026-04-17 13:03:53 -06:00
9 changed files with 597 additions and 70 deletions

View file

@ -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 ###

View file

@ -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
)

View file

@ -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")

View file

@ -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 == "":

View file

@ -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;
}

View file

@ -2,6 +2,32 @@
<a class="nav-link" href="/month/{{ prev_year_month }}" aria-label="Previous month">&larr; {{ prev_year_month }}</a>
<span class="month-label">{{ year_month }}</span>
<a class="nav-link" href="/month/{{ next_year_month }}" aria-label="Next month">{{ next_year_month }} &rarr;</a>
{% if state %}
<span class="state-badge state-{{ state }}">
{% if state == 'closed' and month.closed_at %}
Closed {{ month.closed_at.strftime('%Y-%m-%d') }}
{% else %}
{{ state | capitalize }}
{% endif %}
</span>
{% if state == 'planning' %}
<form class="lifecycle-form" hx-post="/month/{{ year_month }}/activate" hx-swap="none">
<button type="submit" class="primary">Activate</button>
</form>
{% elif state == 'active' %}
<form class="lifecycle-form" hx-post="/month/{{ year_month }}/close" hx-swap="none">
<button
type="submit"
class="primary"
{% if not can_close %}disabled title="Applied balance must equal $0.00 to close"{% endif %}
>Close</button>
</form>
{% elif state == 'closed' %}
<form class="lifecycle-form" hx-post="/month/{{ year_month }}/reopen" hx-swap="none">
<button type="submit" class="secondary">Reopen</button>
</form>
{% endif %}
{% endif %}
<span class="spacer"></span>
{% if all_months %}
<select

View file

@ -24,10 +24,12 @@
type="text"
name="name"
value="{{ row.entry.name }}"
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% if editable %}
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% else %}disabled{% endif %}
>
{% if row.state.value == 'edited' %}
<span class="tag tag-edited">modified</span>
@ -40,10 +42,12 @@
type="number" step="0.01" min="0"
name="planned"
value="{{ '%.2f' | format(row.entry.planned) }}"
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% if editable %}
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% else %}disabled{% endif %}
>
</td>
<td class="entry-amount">
@ -51,21 +55,25 @@
type="number" step="0.01" min="0"
name="applied"
value="{{ '%.2f' | format(row.entry.applied) }}"
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% if editable %}
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
{% else %}disabled{% endif %}
>
</td>
<td class="entry-actions">
<button
class="delete"
type="button"
hx-delete="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
aria-label="Delete {{ row.entry.name }}"
>&times;</button>
{% if editable %}
<button
class="delete"
type="button"
hx-delete="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
aria-label="Delete {{ row.entry.name }}"
>&times;</button>
{% endif %}
</td>
</tr>
<tr class="entry-notes-row">
@ -76,33 +84,37 @@
name="notes"
value="{{ row.entry.notes or '' }}"
placeholder="notes..."
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
aria-label="Notes for {{ row.entry.name }}"
{% if editable %}
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
aria-label="Notes for {{ row.entry.name }}"
{% else %}disabled{% endif %}
>
</td>
</tr>
{% else %}
<tr class="empty"><td colspan="4">No entries.</td></tr>
{% endfor %}
<tr class="add-row">
<td colspan="4">
<form
class="add-form month-add-form"
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()"
>
<input type="text" name="name" placeholder="Name" required>
<input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required>
<button type="submit">Add</button>
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
</form>
</td>
</tr>
{% if editable %}
<tr class="add-row">
<td colspan="4">
<form
class="add-form month-add-form"
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()"
>
<input type="text" name="name" placeholder="Name" required>
<input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required>
<button type="submit">Add</button>
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
</form>
</td>
</tr>
{% endif %}
</tbody>
</table>
</section>

View file

@ -20,27 +20,29 @@
<td class="entry-amount"></td>
<td class="entry-actions"></td>
</tr>
<tr class="add-row">
<td colspan="3">
<form
class="target-form"
hx-post="/month/{{ month.year_month }}/target"
hx-target="#section-debt_target"
hx-swap="outerHTML"
>
<select name="month_entry_id">
<option value="">(none)</option>
{% for dm in debt_minimums %}
<option
value="{{ dm.id }}"
{% if target.month_entry_id == dm.id %}selected{% endif %}
>{{ dm.name }}: ${{ '%.2f' | format(dm.planned) }}</option>
{% endfor %}
</select>
<button type="submit">Set</button>
</form>
</td>
</tr>
{% if editable %}
<tr class="add-row">
<td colspan="3">
<form
class="target-form"
hx-post="/month/{{ month.year_month }}/target"
hx-target="#section-debt_target"
hx-swap="outerHTML"
>
<select name="month_entry_id">
<option value="">(none)</option>
{% for dm in debt_minimums %}
<option
value="{{ dm.id }}"
{% if target.month_entry_id == dm.id %}selected{% endif %}
>{{ dm.name }}: ${{ '%.2f' | format(dm.planned) }}</option>
{% endfor %}
</select>
<button type="submit">Set</button>
</form>
</td>
</tr>
{% endif %}
</tbody>
</table>
</section>

View file

@ -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