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