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>
This commit is contained in:
archeious 2026-04-17 13:03:57 -06:00
parent eb7e47bcbe
commit fa9a397d83

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, datetime, timezone
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import Enum
@ -23,6 +23,7 @@ from quartermaster.models import (
Month, Month,
MonthDebtTarget, MonthDebtTarget,
MonthEntry, MonthEntry,
MonthState,
Section, Section,
) )
@ -306,3 +307,54 @@ def set_month_target(
db.commit() db.commit()
db.refresh(target) db.refresh(target)
return 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")