Month lifecycle: Planning, Active, Closed with reconciliation gate #16
9 changed files with 597 additions and 70 deletions
40
alembic/versions/a4ec4f8f6e9f_add_month_lifecycle_state.py
Normal file
40
alembic/versions/a4ec4f8f6e9f_add_month_lifecycle_state.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""add month lifecycle state
|
||||||
|
|
||||||
|
Revision ID: a4ec4f8f6e9f
|
||||||
|
Revises: ec804bdf366d
|
||||||
|
Create Date: 2026-04-17 12:59:25.811354
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a4ec4f8f6e9f'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'ec804bdf366d'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('month', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('state', sa.Enum('planning', 'active', 'closed', name='monthstate', native_enum=False, length=16), server_default='planning', nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('activated_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('closed_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('month', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('closed_at')
|
||||||
|
batch_op.drop_column('activated_at')
|
||||||
|
batch_op.drop_column('state')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -27,6 +27,12 @@ class Section(str, enum.Enum):
|
||||||
sinking_fund = "sinking_fund"
|
sinking_fund = "sinking_fund"
|
||||||
|
|
||||||
|
|
||||||
|
class MonthState(str, enum.Enum):
|
||||||
|
planning = "planning"
|
||||||
|
active = "active"
|
||||||
|
closed = "closed"
|
||||||
|
|
||||||
|
|
||||||
SECTION_LABELS: dict[Section, str] = {
|
SECTION_LABELS: dict[Section, str] = {
|
||||||
Section.income: "Incomes",
|
Section.income: "Incomes",
|
||||||
Section.fixed_bill: "Fixed Amount Bills",
|
Section.fixed_bill: "Fixed Amount Bills",
|
||||||
|
|
@ -86,6 +92,18 @@ class Month(Base):
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
year_month: Mapped[str] = mapped_column(String(7), nullable=False, unique=True)
|
year_month: Mapped[str] = mapped_column(String(7), nullable=False, unique=True)
|
||||||
|
state: Mapped[MonthState] = mapped_column(
|
||||||
|
Enum(MonthState, native_enum=False, length=16),
|
||||||
|
nullable=False,
|
||||||
|
default=MonthState.planning,
|
||||||
|
server_default=MonthState.planning.value,
|
||||||
|
)
|
||||||
|
activated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
closed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from quartermaster import month_service, service
|
from quartermaster import month_service, service
|
||||||
from quartermaster.db import get_session
|
from quartermaster.db import get_session
|
||||||
from quartermaster.models import SECTION_LABELS, Month, Section
|
from quartermaster.models import SECTION_LABELS, Month, MonthState, Section
|
||||||
from quartermaster.routes import templates
|
from quartermaster.routes import templates
|
||||||
|
|
||||||
router = APIRouter(prefix="/month/{year_month}", tags=["month"])
|
router = APIRouter(prefix="/month/{year_month}", tags=["month"])
|
||||||
|
|
@ -42,6 +42,15 @@ def _require_month(db: Session, year_month: str) -> Month:
|
||||||
return month
|
return month
|
||||||
|
|
||||||
|
|
||||||
|
def _require_editable_month(db: Session, year_month: str) -> Month:
|
||||||
|
month = _require_month(db, year_month)
|
||||||
|
try:
|
||||||
|
month_service.ensure_editable(month)
|
||||||
|
except month_service.MonthLifecycleError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return month
|
||||||
|
|
||||||
|
|
||||||
def _section_views(month: Month) -> list[month_service.MonthSectionView]:
|
def _section_views(month: Month) -> list[month_service.MonthSectionView]:
|
||||||
return [
|
return [
|
||||||
month_service.section_view(month, s, SECTION_LABELS[s]) for s in Section
|
month_service.section_view(month, s, SECTION_LABELS[s]) for s in Section
|
||||||
|
|
@ -55,7 +64,11 @@ def _render_section(
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"partials/month_section.html",
|
"partials/month_section.html",
|
||||||
{"month": month, "section": view},
|
{
|
||||||
|
"month": month,
|
||||||
|
"section": view,
|
||||||
|
"editable": month.state != MonthState.closed,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,7 +79,12 @@ def _render_target(request: Request, db: Session, month: Month) -> HTMLResponse:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"partials/month_target.html",
|
"partials/month_target.html",
|
||||||
{"month": month, "target": target, "debt_minimums": debt_minimums},
|
{
|
||||||
|
"month": month,
|
||||||
|
"target": target,
|
||||||
|
"debt_minimums": debt_minimums,
|
||||||
|
"editable": month.state != MonthState.closed,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -119,6 +137,11 @@ def view_month(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
zero = month_service.month_zero(month)
|
zero = month_service.month_zero(month)
|
||||||
|
editable = month.state != MonthState.closed
|
||||||
|
can_close = (
|
||||||
|
month.state == MonthState.active
|
||||||
|
and zero.applied == Decimal("0.00")
|
||||||
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"month.html",
|
"month.html",
|
||||||
|
|
@ -137,6 +160,9 @@ def view_month(
|
||||||
"zero": zero,
|
"zero": zero,
|
||||||
"planned_tone": service.zero_tone(zero.planned),
|
"planned_tone": service.zero_tone(zero.planned),
|
||||||
"applied_tone": service.zero_tone(zero.applied),
|
"applied_tone": service.zero_tone(zero.applied),
|
||||||
|
"state": month.state.value,
|
||||||
|
"editable": editable,
|
||||||
|
"can_close": can_close,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -167,7 +193,7 @@ def add_month_entry(
|
||||||
notes: str | None = Form(None),
|
notes: str | None = Form(None),
|
||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
month = _require_month(db, year_month)
|
month = _require_editable_month(db, year_month)
|
||||||
clean_name = name.strip()
|
clean_name = name.strip()
|
||||||
if not clean_name:
|
if not clean_name:
|
||||||
raise HTTPException(status_code=400, detail="name is required")
|
raise HTTPException(status_code=400, detail="name is required")
|
||||||
|
|
@ -189,7 +215,7 @@ def delete_month_entry(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
month = _require_month(db, year_month)
|
month = _require_editable_month(db, year_month)
|
||||||
section = month_service.delete_month_entry(db, month, entry_id)
|
section = month_service.delete_month_entry(db, month, entry_id)
|
||||||
if section is None:
|
if section is None:
|
||||||
raise HTTPException(status_code=404, detail="entry not found")
|
raise HTTPException(status_code=404, detail="entry not found")
|
||||||
|
|
@ -214,7 +240,7 @@ def update_month_entry(
|
||||||
notes: str | None = Form(None),
|
notes: str | None = Form(None),
|
||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
month = _require_month(db, year_month)
|
month = _require_editable_month(db, year_month)
|
||||||
clean_name: str | None = None
|
clean_name: str | None = None
|
||||||
if name is not None:
|
if name is not None:
|
||||||
clean_name = name.strip()
|
clean_name = name.strip()
|
||||||
|
|
@ -240,6 +266,54 @@ def update_month_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/activate")
|
||||||
|
def activate_month(
|
||||||
|
year_month: str,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
month = _require_month(db, year_month)
|
||||||
|
try:
|
||||||
|
month_service.activate_month(db, month)
|
||||||
|
except month_service.MonthLifecycleError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return Response(
|
||||||
|
status_code=204,
|
||||||
|
headers={"HX-Redirect": f"/month/{year_month}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/close")
|
||||||
|
def close_month(
|
||||||
|
year_month: str,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
month = _require_month(db, year_month)
|
||||||
|
try:
|
||||||
|
month_service.close_month(db, month)
|
||||||
|
except month_service.MonthLifecycleError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return Response(
|
||||||
|
status_code=204,
|
||||||
|
headers={"HX-Redirect": f"/month/{year_month}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reopen")
|
||||||
|
def reopen_month(
|
||||||
|
year_month: str,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
month = _require_month(db, year_month)
|
||||||
|
try:
|
||||||
|
month_service.reopen_month(db, month)
|
||||||
|
except month_service.MonthLifecycleError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return Response(
|
||||||
|
status_code=204,
|
||||||
|
headers={"HX-Redirect": f"/month/{year_month}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/target", response_class=HTMLResponse)
|
@router.post("/target", response_class=HTMLResponse)
|
||||||
def update_month_target(
|
def update_month_target(
|
||||||
year_month: str,
|
year_month: str,
|
||||||
|
|
@ -247,7 +321,7 @@ def update_month_target(
|
||||||
month_entry_id: str = Form(""),
|
month_entry_id: str = Form(""),
|
||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
month = _require_month(db, year_month)
|
month = _require_editable_month(db, year_month)
|
||||||
raw = month_entry_id.strip()
|
raw = month_entry_id.strip()
|
||||||
target_id: int | None
|
target_id: int | None
|
||||||
if raw == "":
|
if raw == "":
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,66 @@ details.group > .section:last-child {
|
||||||
background: #f0ece0;
|
background: #f0ece0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.month-nav .state-badge {
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-badge.state-planning {
|
||||||
|
background: #e9ecf5;
|
||||||
|
color: #3a4fa1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-badge.state-active {
|
||||||
|
background: #d9f0e4;
|
||||||
|
color: #1f6b44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-badge.state-closed {
|
||||||
|
background: #e8e6dd;
|
||||||
|
color: #4a4a40;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav .lifecycle-form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav .lifecycle-form button {
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav .lifecycle-form button.secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav .lifecycle-form button[disabled] {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: #c5d4ce;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[disabled],
|
||||||
|
select[disabled] {
|
||||||
|
background: #f4f3ef;
|
||||||
|
color: #6b6b6b;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.month-nav .spacer {
|
.month-nav .spacer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,32 @@
|
||||||
<a class="nav-link" href="/month/{{ prev_year_month }}" aria-label="Previous month">← {{ prev_year_month }}</a>
|
<a class="nav-link" href="/month/{{ prev_year_month }}" aria-label="Previous month">← {{ prev_year_month }}</a>
|
||||||
<span class="month-label">{{ year_month }}</span>
|
<span class="month-label">{{ year_month }}</span>
|
||||||
<a class="nav-link" href="/month/{{ next_year_month }}" aria-label="Next month">{{ next_year_month }} →</a>
|
<a class="nav-link" href="/month/{{ next_year_month }}" aria-label="Next month">{{ next_year_month }} →</a>
|
||||||
|
{% if state %}
|
||||||
|
<span class="state-badge state-{{ state }}">
|
||||||
|
{% if state == 'closed' and month.closed_at %}
|
||||||
|
Closed {{ month.closed_at.strftime('%Y-%m-%d') }}
|
||||||
|
{% else %}
|
||||||
|
{{ state | capitalize }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% if state == 'planning' %}
|
||||||
|
<form class="lifecycle-form" hx-post="/month/{{ year_month }}/activate" hx-swap="none">
|
||||||
|
<button type="submit" class="primary">Activate</button>
|
||||||
|
</form>
|
||||||
|
{% elif state == 'active' %}
|
||||||
|
<form class="lifecycle-form" hx-post="/month/{{ year_month }}/close" hx-swap="none">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="primary"
|
||||||
|
{% if not can_close %}disabled title="Applied balance must equal $0.00 to close"{% endif %}
|
||||||
|
>Close</button>
|
||||||
|
</form>
|
||||||
|
{% elif state == 'closed' %}
|
||||||
|
<form class="lifecycle-form" hx-post="/month/{{ year_month }}/reopen" hx-swap="none">
|
||||||
|
<button type="submit" class="secondary">Reopen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
{% if all_months %}
|
{% if all_months %}
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,12 @@
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
value="{{ row.entry.name }}"
|
value="{{ row.entry.name }}"
|
||||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
{% if editable %}
|
||||||
hx-trigger="change"
|
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
hx-target="#section-{{ section.section.value }}"
|
hx-trigger="change"
|
||||||
hx-swap="outerHTML"
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
{% else %}disabled{% endif %}
|
||||||
>
|
>
|
||||||
{% if row.state.value == 'edited' %}
|
{% if row.state.value == 'edited' %}
|
||||||
<span class="tag tag-edited">modified</span>
|
<span class="tag tag-edited">modified</span>
|
||||||
|
|
@ -40,10 +42,12 @@
|
||||||
type="number" step="0.01" min="0"
|
type="number" step="0.01" min="0"
|
||||||
name="planned"
|
name="planned"
|
||||||
value="{{ '%.2f' | format(row.entry.planned) }}"
|
value="{{ '%.2f' | format(row.entry.planned) }}"
|
||||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
{% if editable %}
|
||||||
hx-trigger="change"
|
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
hx-target="#section-{{ section.section.value }}"
|
hx-trigger="change"
|
||||||
hx-swap="outerHTML"
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
{% else %}disabled{% endif %}
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td class="entry-amount">
|
<td class="entry-amount">
|
||||||
|
|
@ -51,21 +55,25 @@
|
||||||
type="number" step="0.01" min="0"
|
type="number" step="0.01" min="0"
|
||||||
name="applied"
|
name="applied"
|
||||||
value="{{ '%.2f' | format(row.entry.applied) }}"
|
value="{{ '%.2f' | format(row.entry.applied) }}"
|
||||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
{% if editable %}
|
||||||
hx-trigger="change"
|
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
hx-target="#section-{{ section.section.value }}"
|
hx-trigger="change"
|
||||||
hx-swap="outerHTML"
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
{% else %}disabled{% endif %}
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td class="entry-actions">
|
<td class="entry-actions">
|
||||||
<button
|
{% if editable %}
|
||||||
class="delete"
|
<button
|
||||||
type="button"
|
class="delete"
|
||||||
hx-delete="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
type="button"
|
||||||
hx-target="#section-{{ section.section.value }}"
|
hx-delete="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
hx-swap="outerHTML"
|
hx-target="#section-{{ section.section.value }}"
|
||||||
aria-label="Delete {{ row.entry.name }}"
|
hx-swap="outerHTML"
|
||||||
>×</button>
|
aria-label="Delete {{ row.entry.name }}"
|
||||||
|
>×</button>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="entry-notes-row">
|
<tr class="entry-notes-row">
|
||||||
|
|
@ -76,33 +84,37 @@
|
||||||
name="notes"
|
name="notes"
|
||||||
value="{{ row.entry.notes or '' }}"
|
value="{{ row.entry.notes or '' }}"
|
||||||
placeholder="notes..."
|
placeholder="notes..."
|
||||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
{% if editable %}
|
||||||
hx-trigger="change"
|
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||||
hx-target="#section-{{ section.section.value }}"
|
hx-trigger="change"
|
||||||
hx-swap="outerHTML"
|
hx-target="#section-{{ section.section.value }}"
|
||||||
aria-label="Notes for {{ row.entry.name }}"
|
hx-swap="outerHTML"
|
||||||
|
aria-label="Notes for {{ row.entry.name }}"
|
||||||
|
{% else %}disabled{% endif %}
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr class="empty"><td colspan="4">No entries.</td></tr>
|
<tr class="empty"><td colspan="4">No entries.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<tr class="add-row">
|
{% if editable %}
|
||||||
<td colspan="4">
|
<tr class="add-row">
|
||||||
<form
|
<td colspan="4">
|
||||||
class="add-form month-add-form"
|
<form
|
||||||
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
|
class="add-form month-add-form"
|
||||||
hx-target="#section-{{ section.section.value }}"
|
hx-post="/month/{{ month.year_month }}/sections/{{ section.section.value }}/entries"
|
||||||
hx-swap="outerHTML"
|
hx-target="#section-{{ section.section.value }}"
|
||||||
hx-on::after-request="if(event.detail.successful) this.reset()"
|
hx-swap="outerHTML"
|
||||||
>
|
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||||
<input type="text" name="name" placeholder="Name" required>
|
>
|
||||||
<input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required>
|
<input type="text" name="name" placeholder="Name" required>
|
||||||
<button type="submit">Add</button>
|
<input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required>
|
||||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
<button type="submit">Add</button>
|
||||||
</form>
|
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||||
</td>
|
</form>
|
||||||
</tr>
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -20,27 +20,29 @@
|
||||||
<td class="entry-amount"></td>
|
<td class="entry-amount"></td>
|
||||||
<td class="entry-actions"></td>
|
<td class="entry-actions"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="add-row">
|
{% if editable %}
|
||||||
<td colspan="3">
|
<tr class="add-row">
|
||||||
<form
|
<td colspan="3">
|
||||||
class="target-form"
|
<form
|
||||||
hx-post="/month/{{ month.year_month }}/target"
|
class="target-form"
|
||||||
hx-target="#section-debt_target"
|
hx-post="/month/{{ month.year_month }}/target"
|
||||||
hx-swap="outerHTML"
|
hx-target="#section-debt_target"
|
||||||
>
|
hx-swap="outerHTML"
|
||||||
<select name="month_entry_id">
|
>
|
||||||
<option value="">(none)</option>
|
<select name="month_entry_id">
|
||||||
{% for dm in debt_minimums %}
|
<option value="">(none)</option>
|
||||||
<option
|
{% for dm in debt_minimums %}
|
||||||
value="{{ dm.id }}"
|
<option
|
||||||
{% if target.month_entry_id == dm.id %}selected{% endif %}
|
value="{{ dm.id }}"
|
||||||
>{{ dm.name }}: ${{ '%.2f' | format(dm.planned) }}</option>
|
{% if target.month_entry_id == dm.id %}selected{% endif %}
|
||||||
{% endfor %}
|
>{{ dm.name }}: ${{ '%.2f' | format(dm.planned) }}</option>
|
||||||
</select>
|
{% endfor %}
|
||||||
<button type="submit">Set</button>
|
</select>
|
||||||
</form>
|
<button type="submit">Set</button>
|
||||||
</td>
|
</form>
|
||||||
</tr>
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
243
tests/test_month_lifecycle.py
Normal file
243
tests/test_month_lifecycle.py
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from quartermaster import month_service, service
|
||||||
|
from quartermaster.models import MonthState, Section
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db, balance_to_zero=False):
|
||||||
|
service.add_entry(db, Section.income, "Paycheck", Decimal("1000.00"))
|
||||||
|
service.add_entry(db, Section.fixed_bill, "Rent", Decimal("600.00"))
|
||||||
|
service.add_entry(db, Section.debt_minimum, "Card A", Decimal("40.00"))
|
||||||
|
service.add_entry(db, Section.food, "Groceries", Decimal("300.00"))
|
||||||
|
service.add_entry(db, Section.other, "Misc", Decimal("60.00"))
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
if balance_to_zero:
|
||||||
|
# Apply income fully and distribute applied to match planned exactly
|
||||||
|
for entry in month.entries:
|
||||||
|
month_service.update_month_entry(
|
||||||
|
db, month, entry.id, applied=entry.planned
|
||||||
|
)
|
||||||
|
db.refresh(month)
|
||||||
|
return month
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_month_defaults_to_planning(db):
|
||||||
|
_seed(db)
|
||||||
|
month = month_service.get_month(db, "2026-04")
|
||||||
|
assert month is not None
|
||||||
|
assert month.state == MonthState.planning
|
||||||
|
assert month.activated_at is None
|
||||||
|
assert month.closed_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_transitions_to_active(db):
|
||||||
|
month = _seed(db)
|
||||||
|
activated = month_service.activate_month(db, month)
|
||||||
|
assert activated.state == MonthState.active
|
||||||
|
assert activated.activated_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_rejects_non_planning_month(db):
|
||||||
|
month = _seed(db)
|
||||||
|
month_service.activate_month(db, month)
|
||||||
|
with pytest.raises(month_service.MonthLifecycleError):
|
||||||
|
month_service.activate_month(db, month)
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_requires_active(db):
|
||||||
|
month = _seed(db)
|
||||||
|
# still planning; close should reject
|
||||||
|
with pytest.raises(month_service.MonthLifecycleError):
|
||||||
|
month_service.close_month(db, month)
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_requires_zero_balance(db):
|
||||||
|
month = _seed(db)
|
||||||
|
month_service.activate_month(db, month)
|
||||||
|
# applied zero is 0 - 0 = 0, but income applied is 0 so
|
||||||
|
# (0 income - 0 others) == 0 actually. Force a non-zero by applying to food.
|
||||||
|
food = next(e for e in month.entries if e.origin_name == "Groceries")
|
||||||
|
month_service.update_month_entry(
|
||||||
|
db, month, food.id, applied=Decimal("50.00")
|
||||||
|
)
|
||||||
|
db.refresh(month)
|
||||||
|
with pytest.raises(month_service.MonthLifecycleError) as exc:
|
||||||
|
month_service.close_month(db, month)
|
||||||
|
assert "0.00" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_succeeds_when_balanced(db):
|
||||||
|
month = _seed(db, balance_to_zero=True)
|
||||||
|
month_service.activate_month(db, month)
|
||||||
|
closed = month_service.close_month(db, month)
|
||||||
|
assert closed.state == MonthState.closed
|
||||||
|
assert closed.closed_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_from_closed(db):
|
||||||
|
month = _seed(db, balance_to_zero=True)
|
||||||
|
month_service.activate_month(db, month)
|
||||||
|
month_service.close_month(db, month)
|
||||||
|
reopened = month_service.reopen_month(db, month)
|
||||||
|
assert reopened.state == MonthState.active
|
||||||
|
assert reopened.closed_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_rejects_non_closed(db):
|
||||||
|
month = _seed(db)
|
||||||
|
with pytest.raises(month_service.MonthLifecycleError):
|
||||||
|
month_service.reopen_month(db, month)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_editable_rejects_closed(db):
|
||||||
|
month = _seed(db, balance_to_zero=True)
|
||||||
|
month_service.activate_month(db, month)
|
||||||
|
month_service.close_month(db, month)
|
||||||
|
with pytest.raises(month_service.MonthLifecycleError):
|
||||||
|
month_service.ensure_editable(month)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_editable_allows_planning_and_active(db):
|
||||||
|
month = _seed(db)
|
||||||
|
month_service.ensure_editable(month) # planning: fine
|
||||||
|
month_service.activate_month(db, month)
|
||||||
|
month_service.ensure_editable(month) # active: fine
|
||||||
|
|
||||||
|
|
||||||
|
# --- route-level -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_via_api(client, balance_to_zero=False):
|
||||||
|
client.post(
|
||||||
|
"/sections/income/entries",
|
||||||
|
data={"name": "Paycheck", "amount": "1000.00"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/sections/fixed_bill/entries",
|
||||||
|
data={"name": "Rent", "amount": "600.00"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/sections/food/entries",
|
||||||
|
data={"name": "Groceries", "amount": "400.00"},
|
||||||
|
)
|
||||||
|
client.post("/month/2026-04/create")
|
||||||
|
if balance_to_zero:
|
||||||
|
# entry ids 1,2,3 in that order
|
||||||
|
client.post(
|
||||||
|
"/month/2026-04/entries/1", data={"applied": "1000.00"}
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/month/2026-04/entries/2", data={"applied": "600.00"}
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/month/2026-04/entries/3", data={"applied": "400.00"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_route(client):
|
||||||
|
_seed_via_api(client)
|
||||||
|
response = client.post("/month/2026-04/activate")
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert response.headers.get("hx-redirect") == "/month/2026-04"
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_route_rejects_unbalanced(client):
|
||||||
|
_seed_via_api(client)
|
||||||
|
client.post("/month/2026-04/activate")
|
||||||
|
# apply some expense without income to produce an unbalanced state
|
||||||
|
client.post("/month/2026-04/entries/2", data={"applied": "100.00"})
|
||||||
|
response = client.post("/month/2026-04/close")
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "0.00" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_route_succeeds_balanced(client):
|
||||||
|
_seed_via_api(client, balance_to_zero=True)
|
||||||
|
client.post("/month/2026-04/activate")
|
||||||
|
response = client.post("/month/2026-04/close")
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert response.headers.get("hx-redirect") == "/month/2026-04"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_route(client):
|
||||||
|
_seed_via_api(client, balance_to_zero=True)
|
||||||
|
client.post("/month/2026-04/activate")
|
||||||
|
client.post("/month/2026-04/close")
|
||||||
|
response = client.post("/month/2026-04/reopen")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutations_rejected_on_closed_month(client):
|
||||||
|
_seed_via_api(client, balance_to_zero=True)
|
||||||
|
client.post("/month/2026-04/activate")
|
||||||
|
client.post("/month/2026-04/close")
|
||||||
|
# try to add, update, delete, and re-set target
|
||||||
|
add_response = client.post(
|
||||||
|
"/month/2026-04/sections/other/entries",
|
||||||
|
data={"name": "Nope", "planned": "10.00"},
|
||||||
|
)
|
||||||
|
assert add_response.status_code == 400
|
||||||
|
update_response = client.post(
|
||||||
|
"/month/2026-04/entries/1", data={"applied": "1200.00"}
|
||||||
|
)
|
||||||
|
assert update_response.status_code == 400
|
||||||
|
delete_response = client.delete("/month/2026-04/entries/1")
|
||||||
|
assert delete_response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_page_shows_planning_badge_and_activate_button(client):
|
||||||
|
_seed_via_api(client)
|
||||||
|
response = client.get("/month/2026-04")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "state-planning" in response.text
|
||||||
|
assert "/activate" in response.text
|
||||||
|
assert "Activate" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_page_shows_active_badge_with_disabled_close_when_unbalanced(client):
|
||||||
|
_seed_via_api(client)
|
||||||
|
client.post("/month/2026-04/activate")
|
||||||
|
# force imbalance
|
||||||
|
client.post("/month/2026-04/entries/2", data={"applied": "100.00"})
|
||||||
|
response = client.get("/month/2026-04")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "state-active" in response.text
|
||||||
|
assert "/close" in response.text
|
||||||
|
assert "disabled" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_page_shows_active_badge_with_enabled_close_when_balanced(client):
|
||||||
|
_seed_via_api(client, balance_to_zero=True)
|
||||||
|
client.post("/month/2026-04/activate")
|
||||||
|
response = client.get("/month/2026-04")
|
||||||
|
assert "state-active" in response.text
|
||||||
|
assert "/close" in response.text
|
||||||
|
# Close button is present without the disabled attribute on the button itself
|
||||||
|
assert 'type="submit"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_page_shows_closed_badge_and_reopen_button(client):
|
||||||
|
_seed_via_api(client, balance_to_zero=True)
|
||||||
|
client.post("/month/2026-04/activate")
|
||||||
|
client.post("/month/2026-04/close")
|
||||||
|
response = client.get("/month/2026-04")
|
||||||
|
assert "state-closed" in response.text
|
||||||
|
assert "/reopen" in response.text
|
||||||
|
assert "Closed" in response.text
|
||||||
|
# add forms hidden on closed month
|
||||||
|
assert "hx-post=\"/month/2026-04/sections/" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_closed_month_inputs_are_disabled(client):
|
||||||
|
_seed_via_api(client, balance_to_zero=True)
|
||||||
|
client.post("/month/2026-04/activate")
|
||||||
|
client.post("/month/2026-04/close")
|
||||||
|
response = client.get("/month/2026-04")
|
||||||
|
# name inputs rendered with disabled
|
||||||
|
assert "disabled" in response.text
|
||||||
|
# delete buttons not rendered
|
||||||
|
assert "Delete Paycheck" not in response.text
|
||||||
Loading…
Reference in a new issue