Finance-Accounts/ar-aging-app/backend/app/api/routes/control.py

179 lines
7.2 KiB
Python

"""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"}