""" Statement generation helpers extracted from API layer. These functions encapsulate database access, validation, and file generation for billing statements so API endpoints can remain thin controllers. """ from __future__ import annotations from typing import Optional, Tuple, List, Dict, Any from pathlib import Path from datetime import datetime, timezone, date from fastapi import HTTPException, status from sqlalchemy.orm import Session, joinedload from app.models.files import File from app.models.ledger import Ledger def _safe_round(value: Optional[float]) -> float: try: return round(float(value or 0.0), 2) except Exception: return 0.0 def parse_period_month(period: Optional[str]) -> Optional[Tuple[date, date]]: """Parse period in the form YYYY-MM and return (start_date, end_date) inclusive. Returns None when period is not provided or invalid. """ if not period: return None import re as _re m = _re.fullmatch(r"(\d{4})-(\d{2})", str(period).strip()) if not m: return None year = int(m.group(1)) month = int(m.group(2)) if month < 1 or month > 12: return None from calendar import monthrange last_day = monthrange(year, month)[1] return date(year, month, 1), date(year, month, last_day) def render_statement_html( *, file_no: str, client_name: Optional[str], matter: Optional[str], as_of_iso: str, period: Optional[str], totals: Dict[str, float], unbilled_entries: List[Dict[str, Any]], ) -> str: """Create a simple, self-contained HTML statement string. The API constructs pydantic models for totals and entries; this helper accepts primitive dicts to avoid coupling to API types. """ def _fmt(val: Optional[float]) -> str: try: return f"{float(val or 0):.2f}" except Exception: return "0.00" rows: List[str] = [] for e in unbilled_entries: date_val = e.get("date") date_str = date_val.isoformat() if hasattr(date_val, "isoformat") else (date_val or "") rows.append( f"{date_str}{e.get('t_code','')}{str(e.get('description','')).replace('<','<').replace('>','>')}" f"{_fmt(e.get('quantity'))}{_fmt(e.get('rate'))}{_fmt(e.get('amount'))}" ) rows_html = "\n".join(rows) if rows else "No unbilled entries" period_html = f"
Period: {period}
" if period else "" html = f""" Statement {file_no}

Statement

\n
File: {file_no}
\n
Client: {client_name or ''}
\n
Matter: {matter or ''}
\n
As of: {as_of_iso}
\n {period_html}
\n
Charges (billed)
${_fmt(totals.get('charges_billed'))}
\n
Charges (unbilled)
${_fmt(totals.get('charges_unbilled'))}
\n
Charges (total)
${_fmt(totals.get('charges_total'))}
\n
Payments
${_fmt(totals.get('payments'))}
\n
Trust balance
${_fmt(totals.get('trust_balance'))}
\n
Current balance
${_fmt(totals.get('current_balance'))}

Unbilled Entries

{rows_html}
Date Code Description Qty Rate Amount
""" return html def generate_single_statement( file_no: str, period: Optional[str], db: Session, ) -> Dict[str, Any]: """Generate a statement for a single file and write an HTML artifact to exports/. Returns a dict matching the "GeneratedStatementMeta" schema expected by the API layer. Raises HTTPException on not found or internal errors. """ file_obj = ( db.query(File) .options(joinedload(File.owner)) .filter(File.file_no == file_no) .first() ) if not file_obj: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"File {file_no} not found", ) # Optional period filtering (YYYY-MM) date_range = parse_period_month(period) q = db.query(Ledger).filter(Ledger.file_no == file_no) if date_range: start_date, end_date = date_range q = q.filter(Ledger.date >= start_date).filter(Ledger.date <= end_date) entries: List[Ledger] = q.all() CHARGE_TYPES = {"2", "3", "4"} charges_billed = sum(e.amount for e in entries if e.t_type in CHARGE_TYPES and e.billed == "Y") charges_unbilled = sum(e.amount for e in entries if e.t_type in CHARGE_TYPES and e.billed != "Y") charges_total = charges_billed + charges_unbilled payments_total = sum(e.amount for e in entries if e.t_type == "5") trust_balance = file_obj.trust_bal or 0.0 current_balance = charges_total - payments_total unbilled_entries: List[Dict[str, Any]] = [ { "id": e.id, "date": e.date, "t_code": e.t_code, "t_type": e.t_type, "description": e.note, "quantity": e.quantity or 0.0, "rate": e.rate or 0.0, "amount": e.amount, } for e in entries if e.t_type in CHARGE_TYPES and e.billed != "Y" ] client_name: Optional[str] = None if file_obj.owner: client_name = f"{file_obj.owner.first or ''} {file_obj.owner.last}".strip() as_of_iso = datetime.now(timezone.utc).isoformat() totals_dict: Dict[str, float] = { "charges_billed": _safe_round(charges_billed), "charges_unbilled": _safe_round(charges_unbilled), "charges_total": _safe_round(charges_total), "payments": _safe_round(payments_total), "trust_balance": _safe_round(trust_balance), "current_balance": _safe_round(current_balance), } # Render HTML html = render_statement_html( file_no=file_no, client_name=client_name or None, matter=file_obj.regarding, as_of_iso=as_of_iso, period=period, totals=totals_dict, unbilled_entries=unbilled_entries, ) # Ensure exports directory and write file exports_dir = Path("exports") try: exports_dir.mkdir(exist_ok=True) except Exception: # Best-effort: if cannot create, bubble up internal error raise HTTPException(status_code=500, detail="Unable to create exports directory") timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f") safe_file_no = str(file_no).replace("/", "_").replace("\\", "_") filename = f"statement_{safe_file_no}_{timestamp}.html" export_path = exports_dir / filename html_bytes = html.encode("utf-8") with open(export_path, "wb") as f: f.write(html_bytes) size = export_path.stat().st_size return { "file_no": file_no, "client_name": client_name or None, "as_of": as_of_iso, "period": period, "totals": totals_dict, "unbilled_count": len(unbilled_entries), "export_path": str(export_path), "filename": filename, "size": size, "content_type": "text/html", }