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
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -24,6 +24,7 @@ from quartermaster.models import (
|
||||||
MonthDebtTarget,
|
MonthDebtTarget,
|
||||||
MonthEntry,
|
MonthEntry,
|
||||||
MonthState,
|
MonthState,
|
||||||
|
Posting,
|
||||||
Section,
|
Section,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -125,7 +126,6 @@ def create_month(db: Session, year_month: str) -> Month:
|
||||||
section=e.section,
|
section=e.section,
|
||||||
name=e.name,
|
name=e.name,
|
||||||
planned=e.amount,
|
planned=e.amount,
|
||||||
applied=Decimal("0.00"),
|
|
||||||
notes=e.notes,
|
notes=e.notes,
|
||||||
origin_name=e.name,
|
origin_name=e.name,
|
||||||
origin_planned=e.amount,
|
origin_planned=e.amount,
|
||||||
|
|
@ -238,7 +238,6 @@ def add_month_entry(
|
||||||
section=section,
|
section=section,
|
||||||
name=name.strip(),
|
name=name.strip(),
|
||||||
planned=planned,
|
planned=planned,
|
||||||
applied=Decimal("0.00"),
|
|
||||||
notes=_clean_notes(notes),
|
notes=_clean_notes(notes),
|
||||||
origin_name=None,
|
origin_name=None,
|
||||||
origin_planned=None,
|
origin_planned=None,
|
||||||
|
|
@ -277,7 +276,6 @@ def update_month_entry(
|
||||||
*,
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
planned: Decimal | None = None,
|
planned: Decimal | None = None,
|
||||||
applied: Decimal | None = None,
|
|
||||||
notes: str | None | object = _NOTES_SENTINEL,
|
notes: str | None | object = _NOTES_SENTINEL,
|
||||||
) -> MonthEntry | None:
|
) -> MonthEntry | None:
|
||||||
entry = get_month_entry(db, month, entry_id)
|
entry = get_month_entry(db, month, entry_id)
|
||||||
|
|
@ -287,8 +285,6 @@ def update_month_entry(
|
||||||
entry.name = name.strip()
|
entry.name = name.strip()
|
||||||
if planned is not None:
|
if planned is not None:
|
||||||
entry.planned = planned
|
entry.planned = planned
|
||||||
if applied is not None:
|
|
||||||
entry.applied = applied
|
|
||||||
if notes is not _NOTES_SENTINEL:
|
if notes is not _NOTES_SENTINEL:
|
||||||
entry.notes = _clean_notes(notes) # type: ignore[arg-type]
|
entry.notes = _clean_notes(notes) # type: ignore[arg-type]
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -296,6 +292,85 @@ def update_month_entry(
|
||||||
return 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:
|
def get_month_target(db: Session, month: Month) -> MonthDebtTarget:
|
||||||
if month.target is not None:
|
if month.target is not None:
|
||||||
return month.target
|
return month.target
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
|
|
@ -242,7 +243,6 @@ def update_month_entry(
|
||||||
request: Request,
|
request: Request,
|
||||||
name: str | None = Form(None),
|
name: str | None = Form(None),
|
||||||
planned: str | None = Form(None),
|
planned: str | None = Form(None),
|
||||||
applied: str | None = Form(None),
|
|
||||||
notes: str | None = Form(None),
|
notes: str | None = Form(None),
|
||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
|
|
@ -253,11 +253,9 @@ def update_month_entry(
|
||||||
if not clean_name:
|
if not clean_name:
|
||||||
raise HTTPException(status_code=400, detail="name must not be empty")
|
raise HTTPException(status_code=400, detail="name must not be empty")
|
||||||
parsed_planned = _parse_amount(planned) if planned is not None else None
|
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(
|
kwargs: dict = dict(
|
||||||
name=clean_name,
|
name=clean_name,
|
||||||
planned=parsed_planned,
|
planned=parsed_planned,
|
||||||
applied=parsed_applied,
|
|
||||||
)
|
)
|
||||||
if notes is not None:
|
if notes is not None:
|
||||||
kwargs["notes"] = notes
|
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)
|
@router.post("/target", response_class=HTMLResponse)
|
||||||
def update_month_target(
|
def update_month_target(
|
||||||
year_month: str,
|
year_month: str,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue