From fa9a397d833ff08f8707b3b026b423fc86234e9e Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 13:03:57 -0600 Subject: [PATCH] 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) --- src/quartermaster/month_service.py | 54 +++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py index 8928aad..9ecc0b9 100644 --- a/src/quartermaster/month_service.py +++ b/src/quartermaster/month_service.py @@ -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")