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:
parent
eb7e47bcbe
commit
fa9a397d83
1 changed files with 53 additions and 1 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue