diff --git a/src/quartermaster/month_service.py b/src/quartermaster/month_service.py index 097adb1..9498345 100644 --- a/src/quartermaster/month_service.py +++ b/src/quartermaster/month_service.py @@ -3,7 +3,7 @@ from __future__ import annotations import re from dataclasses import dataclass from datetime import date, datetime, timezone -from decimal import Decimal +from decimal import Decimal, InvalidOperation from enum import Enum from sqlalchemy import select @@ -24,6 +24,7 @@ from quartermaster.models import ( MonthDebtTarget, MonthEntry, MonthState, + Posting, Section, ) @@ -125,7 +126,6 @@ def create_month(db: Session, year_month: str) -> Month: section=e.section, name=e.name, planned=e.amount, - applied=Decimal("0.00"), notes=e.notes, origin_name=e.name, origin_planned=e.amount, @@ -238,7 +238,6 @@ def add_month_entry( section=section, name=name.strip(), planned=planned, - applied=Decimal("0.00"), notes=_clean_notes(notes), origin_name=None, origin_planned=None, @@ -277,7 +276,6 @@ def update_month_entry( *, name: str | None = None, planned: Decimal | None = None, - applied: Decimal | None = None, notes: str | None | object = _NOTES_SENTINEL, ) -> MonthEntry | None: entry = get_month_entry(db, month, entry_id) @@ -287,8 +285,6 @@ def update_month_entry( entry.name = name.strip() if planned is not None: entry.planned = planned - if applied is not None: - entry.applied = applied if notes is not _NOTES_SENTINEL: entry.notes = _clean_notes(notes) # type: ignore[arg-type] db.commit() @@ -296,6 +292,85 @@ def update_month_entry( return entry +def add_posting( + db: Session, + month: Month, + entry_id: int, + occurred_on: date, + amount: Decimal, + description: str | None = None, + payee: str | None = None, +) -> Posting | None: + entry = get_month_entry(db, month, entry_id) + if entry is None: + return None + posting = Posting( + month_entry_id=entry.id, + occurred_on=occurred_on, + amount=amount, + description=_clean_notes(description), + payee=_clean_notes(payee), + ) + db.add(posting) + db.commit() + db.refresh(posting) + return posting + + +def get_posting( + db: Session, month: Month, posting_id: int +) -> Posting | None: + posting = db.get(Posting, posting_id) + if posting is None: + return None + # Ensure posting belongs to this month via its entry + if posting.entry is None or posting.entry.month_id != month.id: + return None + return posting + + +_POSTING_SENTINEL = object() + + +def update_posting( + db: Session, + month: Month, + posting_id: int, + *, + occurred_on: date | None = None, + amount: Decimal | None = None, + description: str | None | object = _POSTING_SENTINEL, + payee: str | None | object = _POSTING_SENTINEL, +) -> Posting | None: + posting = get_posting(db, month, posting_id) + if posting is None: + return None + if occurred_on is not None: + posting.occurred_on = occurred_on + if amount is not None: + posting.amount = amount + if description is not _POSTING_SENTINEL: + posting.description = _clean_notes(description) # type: ignore[arg-type] + if payee is not _POSTING_SENTINEL: + posting.payee = _clean_notes(payee) # type: ignore[arg-type] + db.commit() + db.refresh(posting) + return posting + + +def delete_posting( + db: Session, month: Month, posting_id: int +) -> MonthEntry | None: + """Delete a posting; return the parent entry so callers can re-render its section.""" + posting = get_posting(db, month, posting_id) + if posting is None: + return None + entry = posting.entry + db.delete(posting) + db.commit() + return entry + + def get_month_target(db: Session, month: Month) -> MonthDebtTarget: if month.target is not None: return month.target diff --git a/src/quartermaster/routes_month.py b/src/quartermaster/routes_month.py index e64fc75..8ff228b 100644 --- a/src/quartermaster/routes_month.py +++ b/src/quartermaster/routes_month.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import date from decimal import Decimal, InvalidOperation from fastapi import APIRouter, Depends, Form, HTTPException, Request @@ -242,7 +243,6 @@ def update_month_entry( request: Request, name: str | None = Form(None), planned: str | None = Form(None), - applied: str | None = Form(None), notes: str | None = Form(None), db: Session = Depends(get_session), ) -> HTMLResponse: @@ -253,11 +253,9 @@ def update_month_entry( if not clean_name: raise HTTPException(status_code=400, detail="name must not be empty") parsed_planned = _parse_amount(planned) if planned is not None else None - parsed_applied = _parse_amount(applied) if applied is not None else None kwargs: dict = dict( name=clean_name, planned=parsed_planned, - applied=parsed_applied, ) if notes is not None: kwargs["notes"] = notes @@ -320,6 +318,109 @@ def reopen_month( ) +def _parse_date(raw: str) -> date: + try: + return date.fromisoformat(raw.strip()) + except (ValueError, AttributeError) as exc: + raise HTTPException( + status_code=400, detail="date must be YYYY-MM-DD" + ) from exc + + +def _parse_signed_amount(raw: str) -> Decimal: + try: + amount = Decimal(raw.strip()) + except (InvalidOperation, AttributeError) as exc: + raise HTTPException( + status_code=400, detail="amount must be numeric" + ) from exc + return amount.quantize(Decimal("0.01")) + + +@router.post("/entries/{entry_id}/postings", response_class=HTMLResponse) +def create_posting( + year_month: str, + entry_id: int, + request: Request, + occurred_on: str = Form(...), + amount: str = Form(...), + description: str | None = Form(None), + payee: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_editable_month(db, year_month) + posting = month_service.add_posting( + db, + month, + entry_id, + _parse_date(occurred_on), + _parse_signed_amount(amount), + description=description, + payee=payee, + ) + if posting is None: + raise HTTPException(status_code=404, detail="entry not found") + db.refresh(month) + entry = month_service.get_month_entry(db, month, entry_id) + return _append_oob( + _render_section(request, month, entry.section), + _render_zero(request, month), + _render_group_totals(request, month), + ) + + +@router.post("/postings/{posting_id}", response_class=HTMLResponse) +def update_posting( + year_month: str, + posting_id: int, + request: Request, + occurred_on: str | None = Form(None), + amount: str | None = Form(None), + description: str | None = Form(None), + payee: str | None = Form(None), + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_editable_month(db, year_month) + kwargs: dict = {} + if occurred_on is not None: + kwargs["occurred_on"] = _parse_date(occurred_on) + if amount is not None: + kwargs["amount"] = _parse_signed_amount(amount) + if description is not None: + kwargs["description"] = description + if payee is not None: + kwargs["payee"] = payee + updated = month_service.update_posting(db, month, posting_id, **kwargs) + if updated is None: + raise HTTPException(status_code=404, detail="posting not found") + db.refresh(month) + entry = updated.entry + return _append_oob( + _render_section(request, month, entry.section), + _render_zero(request, month), + _render_group_totals(request, month), + ) + + +@router.delete("/postings/{posting_id}", response_class=HTMLResponse) +def delete_posting( + year_month: str, + posting_id: int, + request: Request, + db: Session = Depends(get_session), +) -> HTMLResponse: + month = _require_editable_month(db, year_month) + entry = month_service.delete_posting(db, month, posting_id) + if entry is None: + raise HTTPException(status_code=404, detail="posting not found") + db.refresh(month) + return _append_oob( + _render_section(request, month, entry.section), + _render_zero(request, month), + _render_group_totals(request, month), + ) + + @router.post("/target", response_class=HTMLResponse) def update_month_target( year_month: str,