"""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, Request from pydantic import BaseModel from sqlalchemy.orm import Session as OrmSession from ...core.money import USD, Total, to_usd from ...db import models from ..auth import actor_name from ..deps import db_dep, ensure_editable, ensure_not_blocked, 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), ] METRIC_KEYS = ("gross_sales", "refunds", "net_revenue", "disbursements", "closing_receivable") def _dashboard_metrics(db: OrmSession, session_id: int) -> dict[str, float] | None: """ Whole-close figures in **USD**, summed across every marketplace in the session. Each marketplace's movement is stated in its own local currency, so every figure is converted at that marketplace's session FX rate before being added. Summing the locals (which this used to do) understated the Jan-2026 close by USD 444,658.44 — and that was the figure gating sign-off. `Total.add_converted` makes the raw sum impossible. """ from .ar import _market_list, _movement_for, fx_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: Total(USD) for k in METRIC_KEYS} for mkt in _market_list(payload): mv = _movement_for(db, session_id, mkt) if not mv.get("available"): continue rate, _currency = fx_for(db, session_id, mkt) lines = mv.pop("journal").get("lines", []) local = { "gross_sales": next((l["total"] for l in lines if l["key"] == "Sales"), 0.0), "refunds": next((l["total"] for l in lines if l["key"] == "Refunds"), 0.0), "net_revenue": mv["net_revenue"], "disbursements": mv["received_payouts"], "closing_receivable": mv["closing"], } for k, v in local.items(): # Round per marketplace, exactly as the All-Markets roll-up does, so the two # surfaces agree to the cent by construction. Accumulating unrounded here left a # 1-cent gap on the 13-market Jan-2026 close — right on control C6's tolerance, # which would eventually fire as a false alarm on pure floating-point noise. totals[k].add(to_usd(v, rate), USD) return {k: t.value for k, t 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, "currency": USD, # every figure here is USD-converted, never a mix of locals "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: ensure_editable(get_session_or_404(session_id, db)) fc = _get_or_create(db, session_id) data = body.model_dump(exclude_unset=True) # A sign-off attests to specific numbers. If any control figure or the tolerance changes, # the previous attestation no longer applies and must be re-given — otherwise a closing can # read "verified by X" against figures X never saw. invalidates = {k for k in data if k in METRIC_KEYS or k == "tolerance"} changed = {k for k in invalidates if getattr(fc, k, None) != data[k]} for k, v in data.items(): setattr(fc, k, v) if changed and (fc.verified_by or fc.verified_at): fc.verified_by = "" fc.verified_at = None fc.comment = (f"Sign-off cleared automatically: {', '.join(sorted(changed))} " f"changed after verification.") db.commit() return get_control(session_id, db) class VerifyIn(BaseModel): verified_by: str = "" # ignored when signed in — the verified identity wins comment: str = "" @router.post("/{session_id}/reconciliation-control/verify") def verify_control(session_id: int, body: VerifyIn, request: Request, db: OrmSession = Depends(db_dep)) -> dict: ensure_editable(get_session_or_404(session_id, db)) who = actor_name(request, body.verified_by) if not who: raise HTTPException(400, "verified_by is required — the control is verified by a person.") fc = _get_or_create(db, session_id) fc.verified_by = who 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) ensure_not_blocked(s) 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"}