"""Reconciliation control: dashboard figures vs the Finance control sheet, with sign-off.""" from __future__ import annotations import datetime as dt import json from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session as OrmSession from ...core.movement import compute_movement from ...db import models from ..deps import db_dep, get_session_or_404 router = APIRouter(prefix="/api/sessions", tags=["control"]) # metric key -> (label, is_critical) METRICS = [ ("gross_sales", "Gross Sales", False), ("refunds", "Refunds", False), ("net_revenue", "Net Revenue", False), ("disbursements", "Disbursements", False), ("closing_receivable", "Closing Receivable", True), ] def _dashboard_metrics(db: OrmSession, session_id: int) -> dict[str, float] | None: """Whole-close figures: summed across every marketplace in the session. (Identical to the single-market numbers for a USA-only close.) """ from .ar import _market_list, _movement_for j = db.query(models.JournalEntry).filter(models.JournalEntry.session_id == session_id).first() if not j or not j.data: return None payload = json.loads(j.data) totals = {k: 0.0 for k in ("gross_sales", "refunds", "net_revenue", "disbursements", "closing_receivable")} for mkt in _market_list(payload): mv = _movement_for(db, session_id, mkt) if not mv.get("available"): continue lines = mv.pop("journal").get("lines", []) totals["gross_sales"] += next((l["total"] for l in lines if l["key"] == "Sales"), 0.0) totals["refunds"] += next((l["total"] for l in lines if l["key"] == "Refunds"), 0.0) totals["net_revenue"] += mv["net_revenue"] totals["disbursements"] += mv["received_payouts"] totals["closing_receivable"] += mv["closing"] return {k: round(v, 2) for k, v in totals.items()} class ControlIn(BaseModel): gross_sales: float | None = None refunds: float | None = None net_revenue: float | None = None disbursements: float | None = None closing_receivable: float | None = None tolerance: float | None = None comment: str | None = None @router.get("/{session_id}/reconciliation-control") def get_control(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: get_session_or_404(session_id, db) dash = _dashboard_metrics(db, session_id) if dash is None: return {"available": False} fc = db.query(models.FinanceControl).filter( models.FinanceControl.session_id == session_id).first() tol = fc.tolerance if fc and fc.tolerance is not None else 1.0 rows = [] all_ok = True for key, label, critical in METRICS: dv = dash[key] fv = getattr(fc, key) if fc else None diff = round(dv - fv, 2) if fv is not None else None if fv is None: status = "pending" elif abs(diff) <= tol: status = "matched" else: status = "review" if critical and status != "matched": all_ok = False rows.append({"key": key, "label": label, "critical": critical, "dashboard": dv, "finance": fv, "difference": diff, "status": status}) return { "available": True, "tolerance": tol, "rows": rows, "verified_by": fc.verified_by if fc else "", "verified_at": fc.verified_at.isoformat() if fc and fc.verified_at else None, "comment": fc.comment if fc else "", "can_complete": all_ok, "completed": db.get(models.Session, session_id).status == "completed", } def _get_or_create(db: OrmSession, session_id: int) -> models.FinanceControl: fc = db.query(models.FinanceControl).filter( models.FinanceControl.session_id == session_id).first() if fc is None: fc = models.FinanceControl(session_id=session_id) db.add(fc) return fc @router.put("/{session_id}/reconciliation-control") def put_control(session_id: int, body: ControlIn, db: OrmSession = Depends(db_dep)) -> dict: get_session_or_404(session_id, db) fc = _get_or_create(db, session_id) data = body.model_dump(exclude_unset=True) for k, v in data.items(): setattr(fc, k, v) db.commit() return get_control(session_id, db) class VerifyIn(BaseModel): verified_by: str comment: str = "" @router.post("/{session_id}/reconciliation-control/verify") def verify_control(session_id: int, body: VerifyIn, db: OrmSession = Depends(db_dep)) -> dict: get_session_or_404(session_id, db) fc = _get_or_create(db, session_id) fc.verified_by = body.verified_by fc.verified_at = dt.datetime.utcnow() if body.comment: fc.comment = body.comment db.commit() return get_control(session_id, db) @router.post("/{session_id}/complete") def complete_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: s = get_session_or_404(session_id, db) ctrl = get_control(session_id, db) if not ctrl.get("available"): raise HTTPException(400, "Process the closing before completing it.") if not ctrl.get("can_complete"): raise HTTPException(400, "Closing receivable is not reconciled with the Finance control sheet.") s.status = "completed" db.commit() return {"status": "completed"}