"""Session (month-end closing) CRUD and parameters.""" from __future__ import annotations import logging from datetime import date from fastapi import APIRouter, Depends, HTTPException 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 ..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, 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.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, 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" 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, 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.") from ...services.store import purge_session return purge_session(db, session_id)