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