Month lifecycle: Planning, Active, Closed with reconciliation gate #16

Merged
claude-code merged 4 commits from feat/15-month-lifecycle into main 2026-04-17 13:05:00 -06:00
Collaborator

Closes #15 (will be manually closed on merge).

Summary

Each month now moves through three explicit states. Closing requires the applied balance to be exactly $0.00. Nothing is swept automatically; the target row is just a hint about where leftover dollars should go.

  • MonthState enum added; state, activated_at, closed_at columns on month via alembic batch migration
  • Fresh months land in Planning
  • POST /month/YYYY-MM/activate moves Planning -> Active, stamps activated_at
  • POST /month/YYYY-MM/close validates applied_zero == 0, moves Active -> Closed, stamps closed_at
  • POST /month/YYYY-MM/reopen moves Closed -> Active, nulls closed_at
  • Every existing mutation route (add, update, delete, target) rejects closed months with 400 via _require_editable_month
  • Month nav shows a coloured state badge and the correct lifecycle button. Close is disabled with a hover tooltip when balance isn't $0.00.
  • Closed months render with disabled inputs, hidden delete buttons, no add forms, no target selector

Test plan

  • uv run pytest passes (102/102, +20 new)
  • uv run alembic upgrade head applies and downgrades cleanly
  • Live smoke on throwaway DB walked the full lifecycle:
    • Create -> Planning badge + Activate button
    • Activate -> Active badge + Close button; Close disabled with tooltip when unbalanced
    • Apply expense without income -> close returns 400 ("applied balance must equal $0.00")
    • Balance to zero -> close returns 204 + HX-Redirect
    • Closed page has disabled inputs, no add forms, no delete buttons, Reopen button present
    • POST to any mutation endpoint on a closed month returns 400
    • Reopen returns 204 and page flips back to Active

Schema change

ALTER TABLE month ADD COLUMN state VARCHAR(16) NOT NULL DEFAULT 'planning';
ALTER TABLE month ADD COLUMN activated_at DATETIME NULL;
ALTER TABLE month ADD COLUMN closed_at DATETIME NULL;

Out of scope

  • Active -> Planning reverse transition
  • Automatic sweep on close
  • Auto-activation based on calendar date
  • Close audit log beyond the two timestamps
Closes #15 (will be manually closed on merge). ## Summary Each month now moves through three explicit states. Closing requires the applied balance to be exactly `$0.00`. Nothing is swept automatically; the target row is just a hint about where leftover dollars should go. * `MonthState` enum added; `state`, `activated_at`, `closed_at` columns on `month` via alembic batch migration * Fresh months land in Planning * `POST /month/YYYY-MM/activate` moves Planning -> Active, stamps `activated_at` * `POST /month/YYYY-MM/close` validates `applied_zero == 0`, moves Active -> Closed, stamps `closed_at` * `POST /month/YYYY-MM/reopen` moves Closed -> Active, nulls `closed_at` * Every existing mutation route (add, update, delete, target) rejects closed months with 400 via `_require_editable_month` * Month nav shows a coloured state badge and the correct lifecycle button. Close is `disabled` with a hover tooltip when balance isn't `$0.00`. * Closed months render with disabled inputs, hidden delete buttons, no add forms, no target selector ## Test plan * [x] `uv run pytest` passes (102/102, +20 new) * [x] `uv run alembic upgrade head` applies and downgrades cleanly * [x] Live smoke on throwaway DB walked the full lifecycle: * [x] Create -> Planning badge + Activate button * [x] Activate -> Active badge + Close button; Close disabled with tooltip when unbalanced * [x] Apply expense without income -> close returns 400 ("applied balance must equal $0.00") * [x] Balance to zero -> close returns 204 + HX-Redirect * [x] Closed page has disabled inputs, no add forms, no delete buttons, Reopen button present * [x] POST to any mutation endpoint on a closed month returns 400 * [x] Reopen returns 204 and page flips back to Active ## Schema change ``` ALTER TABLE month ADD COLUMN state VARCHAR(16) NOT NULL DEFAULT 'planning'; ALTER TABLE month ADD COLUMN activated_at DATETIME NULL; ALTER TABLE month ADD COLUMN closed_at DATETIME NULL; ``` ## Out of scope * Active -> Planning reverse transition * Automatic sweep on close * Auto-activation based on calendar date * Close audit log beyond the two timestamps
claude-code added 4 commits 2026-04-17 13:04:30 -06:00
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>
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>
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>
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>
claude-code merged commit 961ea46669 into main 2026-04-17 13:05:00 -06:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: archeious/quartermaster#16
No description provided.