Finance-Accounts/ar-aging-app/backend/app/api/routes/sessions.py

169 lines
6.7 KiB
Python

"""Session (month-end closing) CRUD and parameters."""
from __future__ import annotations
import logging
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session as OrmSession
from ...config import DEFAULT_CLEARING_LAG_DAYS, DEFAULT_TOLERANCE
from ...db import models
from ...services.audit import record as audit
from ..deps import db_dep, get_session_or_404, session_dict
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
logger = logging.getLogger(__name__)
class SessionCreate(BaseModel):
name: str
month_end_date: date | None = None
reporting_currency: str = "USD"
clearing_lag_days: int = DEFAULT_CLEARING_LAG_DAYS
rounding_tolerance: float = DEFAULT_TOLERANCE
allowance_for_returns: float = 0.0
# zero (default) | carry_forward | manual
opening_mode: str = "zero"
opening_source_session_id: int | None = None
# Two closings for one month is almost always an accident (two competing datasets for
# the same period); creating a second one requires this explicit flag.
allow_duplicate: bool = False
class SessionUpdate(BaseModel):
name: str | None = None
month_end_date: date | None = None
reporting_currency: str | None = None
clearing_lag_days: int | None = None
rounding_tolerance: float | None = None
allowance_for_returns: float | None = None
manual_adjustment: float | None = None
manual_adjustment_note: str | None = None
opening_mode: str | None = None
opening_source_session_id: int | None = None
def _approved_session_ids(db: OrmSession) -> set[int]:
rows = db.query(models.JournalEntry.session_id).filter(
models.JournalEntry.approved_by != "").all()
return {r[0] for r in rows}
@router.get("")
def list_sessions(db: OrmSession = Depends(db_dep)) -> list[dict]:
"""Every closing, newest month first — the dashboard reads as a month timeline."""
rows = db.query(models.Session).all()
# reporting_month is "YYYY-MM" so string sort == chronological; sessions without a
# month (never given a month-end date) sort last, newest created first.
rows.sort(key=lambda s: (s.reporting_month or "",
s.created_at.isoformat() if s.created_at else ""), reverse=True)
approved = _approved_session_ids(db)
months_seen: dict[str, int] = {}
for s in rows:
if s.reporting_month:
months_seen[s.reporting_month] = months_seen.get(s.reporting_month, 0) + 1
out = []
for s in rows:
d = session_dict(s)
# "Published" = the journal is approved, which is what puts the month on the
# cross-month Accounts Summary.
d["journal_approved"] = s.id in approved
d["duplicate_month"] = bool(s.reporting_month
and months_seen.get(s.reporting_month, 0) > 1)
out.append(d)
return out
@router.post("")
def create_session(body: SessionCreate, request: Request,
db: OrmSession = Depends(db_dep)) -> dict:
me = body.month_end_date
month = me.strftime("%Y-%m") if me else None
if month and not body.allow_duplicate:
clash = db.query(models.Session).filter(
models.Session.reporting_month == month,
models.Session.status != "error").first()
if clash is not None:
raise HTTPException(
409,
f"A closing for {month} already exists ('{clash.name}', id {clash.id}). "
f"Open that closing instead — or pass allow_duplicate to deliberately "
f"create a second one.",
)
s = models.Session(
name=body.name,
month_end_date=me,
reporting_month=month,
reporting_currency=body.reporting_currency,
clearing_lag_days=body.clearing_lag_days,
rounding_tolerance=body.rounding_tolerance,
allowance_for_returns=body.allowance_for_returns,
opening_mode=body.opening_mode or "zero",
opening_source_session_id=body.opening_source_session_id,
status="draft",
)
db.add(s)
db.flush() # assign s.id so the audit row can reference it
audit(db, request, "session_create", session=s,
detail=f"month {month or '(none)'}")
db.commit()
from .ar import seed_opening_from_prior
seed_opening_from_prior(db, s)
return session_dict(s)
@router.get("/{session_id}")
def get_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
return session_dict(get_session_or_404(session_id, db))
@router.patch("/{session_id}")
def update_session(session_id: int, body: SessionUpdate,
db: OrmSession = Depends(db_dep)) -> dict:
s = get_session_or_404(session_id, db)
data = body.model_dump(exclude_unset=True)
if s.status == "completed" and set(data) - {"name"}:
raise HTTPException(409, "This closing is completed and locked — only the name can "
"be changed. Reopen it first for anything else.")
for k, v in data.items():
setattr(s, k, v)
if "month_end_date" in data and s.month_end_date:
s.reporting_month = s.month_end_date.strftime("%Y-%m")
db.commit()
return session_dict(s)
@router.post("/{session_id}/reopen")
def reopen_session(session_id: int, request: Request,
db: OrmSession = Depends(db_dep)) -> dict:
"""Unlock a completed closing for corrections. Deliberate and logged — the opposite of
silently editing published history."""
s = get_session_or_404(session_id, db)
if s.status != "completed":
raise HTTPException(409, "Only a completed closing can be reopened.")
s.status = "blocked" if s.blocked_reason else "processed"
audit(db, request, "session_reopen", session=s)
db.commit()
logger.warning("closing %s (%s, %s) reopened for corrections",
s.id, s.name, s.reporting_month or "no month")
return session_dict(s)
@router.delete("/{session_id}")
def delete_session(session_id: int, request: Request,
db: OrmSession = Depends(db_dep)) -> dict:
"""Delete a closing and every row/file that belongs to it."""
s = get_session_or_404(session_id, db)
if s.status in ("processing", "exporting"):
raise HTTPException(409, "This closing is still processing — wait for it to finish.")
# Recorded up front (audit rows carry no FK, so they survive the purge); committed
# here so the entry exists even though purge_session manages its own transaction.
audit(db, request, "session_delete", session=s,
detail=f"month {s.reporting_month or '(none)'}, status {s.status}")
db.commit()
from ...services.store import purge_session
return purge_session(db, session_id)