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:
parent
517578f4f3
commit
52bc52ec7f
2 changed files with 185 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue