feat(ledger): service CRUD for postings and three new routes

add_posting / update_posting / delete_posting all go through
ensure_editable(month), so closed months reject posting mutations
with the same lifecycle guard as every other mutation. Negative
amounts are allowed for refunds / corrections. Dates are parsed as
ISO (YYYY-MM-DD) but not constrained to the month for now.

update_month_entry loses the applied keyword; the route no longer
accepts an applied form field. applied is derived only from now
on. Three new routes wire the ledger:

  POST   /month/{ym}/entries/{entry_id}/postings
  POST   /month/{ym}/postings/{posting_id}
  DELETE /month/{ym}/postings/{posting_id}

Each returns the updated section partial plus OOB swaps for the
zero widget and all four group totals, same pattern the existing
mutations use.

Refs #19

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
archeious 2026-04-17 17:34:41 -06:00
parent 517578f4f3
commit 52bc52ec7f
2 changed files with 185 additions and 9 deletions

View file

@ -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

View file

@ -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,