This commit is contained in:
HotSwapp
2025-08-18 20:20:04 -05:00
parent 89b2bc0aa2
commit bac8cc4bd5
114 changed files with 30258 additions and 1341 deletions

View File

@@ -0,0 +1,237 @@
"""
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"<tr><td>{date_str}</td><td>{e.get('t_code','')}</td><td>{str(e.get('description','')).replace('<','&lt;').replace('>','&gt;')}</td>"
f"<td style='text-align:right'>{_fmt(e.get('quantity'))}</td><td style='text-align:right'>{_fmt(e.get('rate'))}</td><td style='text-align:right'>{_fmt(e.get('amount'))}</td></tr>"
)
rows_html = "\n".join(rows) if rows else "<tr><td colspan='6' style='text-align:center;color:#666'>No unbilled entries</td></tr>"
period_html = f"<div><strong>Period:</strong> {period}</div>" if period else ""
html = f"""
<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<title>Statement {file_no}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif; margin: 24px; }}
h1 {{ margin: 0 0 8px 0; }}
.meta {{ color: #444; margin-bottom: 16px; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px; font-size: 14px; }}
th {{ background: #f6f6f6; text-align: left; }}
.totals {{ margin: 16px 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; }}
.totals div {{ background: #fafafa; border: 1px solid #eee; padding: 8px; }}
</style>
</head>
<body>
<h1>Statement</h1>
<div class=\"meta\">\n <div><strong>File:</strong> {file_no}</div>\n <div><strong>Client:</strong> {client_name or ''}</div>\n <div><strong>Matter:</strong> {matter or ''}</div>\n <div><strong>As of:</strong> {as_of_iso}</div>\n {period_html}
</div>
<div class=\"totals\">\n <div><strong>Charges (billed)</strong><br/>${_fmt(totals.get('charges_billed'))}</div>\n <div><strong>Charges (unbilled)</strong><br/>${_fmt(totals.get('charges_unbilled'))}</div>\n <div><strong>Charges (total)</strong><br/>${_fmt(totals.get('charges_total'))}</div>\n <div><strong>Payments</strong><br/>${_fmt(totals.get('payments'))}</div>\n <div><strong>Trust balance</strong><br/>${_fmt(totals.get('trust_balance'))}</div>\n <div><strong>Current balance</strong><br/>${_fmt(totals.get('current_balance'))}</div>
</div>
<h2>Unbilled Entries</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>Code</th>
<th>Description</th>
<th style=\"text-align:right\">Qty</th>
<th style=\"text-align:right\">Rate</th>
<th style=\"text-align:right\">Amount</th>
</tr>
</thead>
<tbody>
{rows_html}
</tbody>
</table>
</body>
</html>
"""
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",
}