Backing transaction ledger: Postings replace the applied field #20

Merged
claude-code merged 7 commits from feat/19-posting-ledger into main 2026-04-17 17:54:16 -06:00
2 changed files with 185 additions and 9 deletions
Showing only changes of commit 52bc52ec7f - Show all commits

View file

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

View file

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