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

317 lines
15 KiB
Python

"""Read endpoints for the dashboard: summary, settlements, transactions, exceptions, etc."""
from __future__ import annotations
import datetime as dt
import json
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session as OrmSession
from ...core.receivable import AGING_SCHEMES, aging_bands, classify_aging
from ...core.settlements import RECEIVABLE_ACCOUNT_TYPES
from ...db import models
from ..auth import actor_name
from ..deps import (blocked_payload, db_dep, ensure_editable, get_session_or_404, is_blocked,
to_dict)
router = APIRouter(prefix="/api/sessions", tags=["results"])
# Amazon closes a settlement roughly every two weeks; a receivable is not past due until that
# cycle plus the clearing lag has elapsed. Used only by the aging bands.
SETTLEMENT_CYCLE_DAYS = 14
@router.get("/{session_id}/summary")
def summary(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
s = get_session_or_404(session_id, db)
# Headline receivable: withheld entirely while a month-end control is failing.
if is_blocked(s):
return blocked_payload(s)
setts = db.query(models.Settlement).filter(models.Settlement.session_id == session_id).all()
recon = db.query(models.ReconciliationRow).filter(
models.ReconciliationRow.session_id == session_id).first()
rec_rows = db.query(models.ReceivableResultRow).filter(
models.ReceivableResultRow.session_id == session_id,
models.ReceivableResultRow.account_type == "TOTAL").all()
exc = db.query(models.Exception_.severity, func.count()).filter(
models.Exception_.session_id == session_id).group_by(models.Exception_.severity).all()
n_txn = db.query(func.count(models.Transaction.id)).filter(
models.Transaction.session_id == session_id).scalar() or 0
n_recv_txn = db.query(func.count(models.Transaction.id)).filter(
models.Transaction.session_id == session_id,
models.Transaction.receivable_flag == True).scalar() or 0 # noqa: E712
receivable_settlements = [s for s in setts if s.status == "receivable"]
return {
"closing_receivable_usd": recon.final_receivable_usd if recon else None,
# Self-consistency of the bucketing only — see core/reconciliation.py. The month-end
# controls (/controls) are what tells you whether the close can be trusted.
"bucket_identity_status": recon.status if recon else None,
"reconciliation_status": recon.status if recon else None, # deprecated alias
"reserve_total": recon.reserve_total if recon else 0.0,
"transfers_total": recon.transfers_total if recon else 0.0,
"receivable_orders": recon.receivable_orders if recon else 0.0,
"paid_orders": recon.paid_orders if recon else 0.0,
"num_settlements": len(setts),
"num_receivable_settlements": len(receivable_settlements),
"num_paid_settlements": len(setts) - len(receivable_settlements),
"num_transactions": n_txn,
"num_receivable_transactions": n_recv_txn,
"exceptions_by_severity": {sev: c for sev, c in exc},
"receivable_by_marketplace": [
{"marketplace": r.marketplace, "receivable_local": round(r.receivable_local),
"receivable_usd": r.receivable_usd, "currency": r.currency} for r in rec_rows
],
}
@router.get("/{session_id}/receivable")
def receivable(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]:
get_session_or_404(session_id, db)
rows = db.query(models.ReceivableResultRow).filter(
models.ReceivableResultRow.session_id == session_id).all()
return [to_dict(r, ["marketplace", "account_type", "additional_sales", "reserve",
"receivable_local", "fx_rate", "receivable_usd", "currency"]) for r in rows]
@router.get("/{session_id}/settlements")
def settlements(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]:
get_session_or_404(session_id, db)
rows = db.query(models.Settlement).filter(
models.Settlement.session_id == session_id).order_by(
models.Settlement.marketplace, models.Settlement.account_type,
models.Settlement.settlement_id).all()
return [to_dict(r, ["marketplace", "account_type", "settlement_id", "order_total",
"transfer_total", "transfer_amount", "transfer_date", "transfer_received",
"row_count", "first_date", "last_date", "status"]) for r in rows]
@router.get("/{session_id}/transactions")
def transactions(session_id: int, db: OrmSession = Depends(db_dep),
limit: int = Query(100, le=1000), offset: int = 0,
settlement_id: str | None = None, txn_type: str | None = None,
marketplace: str | None = None, receivable: bool | None = None,
storage: bool | None = None, search: str | None = None) -> dict:
get_session_or_404(session_id, db)
q = db.query(models.Transaction).filter(models.Transaction.session_id == session_id)
if settlement_id:
q = q.filter(models.Transaction.settlement_id == settlement_id)
if txn_type:
# match either the original (possibly localized) type or the canonical English one
q = q.filter((models.Transaction.txn_type == txn_type)
| (models.Transaction.txn_type_en == txn_type))
if marketplace:
q = q.filter(models.Transaction.marketplace == marketplace)
if receivable is not None:
q = q.filter(models.Transaction.receivable_flag == receivable)
if storage is not None:
q = q.filter(models.Transaction.storage_flag == storage)
if search:
like = f"%{search}%"
q = q.filter((models.Transaction.order_id.like(like)) | (models.Transaction.sku.like(like)))
total = q.count()
rows = q.order_by(models.Transaction.id).offset(offset).limit(limit).all()
return {
"total": total, "limit": limit, "offset": offset,
"rows": [to_dict(r, ["id", "source_file", "source_row", "marketplace", "settlement_id",
"order_id", "sku", "txn_type", "txn_type_en", "account_type",
"posted_date", "total", "currency", "settlement_status",
"receivable_flag", "storage_flag"])
for r in rows],
}
@router.get("/{session_id}/exceptions")
def exceptions(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]:
get_session_or_404(session_id, db)
rows = db.query(models.Exception_).filter(
models.Exception_.session_id == session_id).all()
return [to_dict(r, ["category", "severity", "detail", "source"]) for r in rows]
@router.get("/{session_id}/reconciliation")
def reconciliation(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
get_session_or_404(session_id, db)
r = db.query(models.ReconciliationRow).filter(
models.ReconciliationRow.session_id == session_id).first()
if not r:
return {}
d = to_dict(r, ["uploaded_total", "receivable_orders", "paid_orders", "transfers_total",
"reserve_total", "manual_adjustments", "final_receivable_usd",
"identity_difference", "status"])
try:
d["notes"] = json.loads(r.notes) if r.notes else []
except Exception:
d["notes"] = []
return d
@router.get("/{session_id}/journal")
def journal(session_id: int, marketplace: str | None = None,
db: OrmSession = Depends(db_dep)) -> dict:
"""One marketplace's journal (default: the primary one), plus the sign-off state.
The sign-off (review → approval) is per CLOSING, not per marketplace — approving
publishes every marketplace's journal for the month to the Accounts Summary."""
from .ar import _journal_for, _market_list
get_session_or_404(session_id, db)
j = db.query(models.JournalEntry).filter(
models.JournalEntry.session_id == session_id).first()
if not j or not j.data:
return {"available": False}
payload = json.loads(j.data)
markets = _market_list(payload)
data, mkt = _journal_for(payload, marketplace)
data = dict(data) # never mutate the stored payload
data["available"] = True
data["marketplace"] = mkt
data["marketplaces"] = markets
data["entry_no"] = j.entry_no or ""
data["reviewed_by"] = j.reviewed_by or ""
data["reviewed_at"] = j.reviewed_at.isoformat() if j.reviewed_at else None
data["approved_by"] = j.approved_by or ""
data["approved_at"] = j.approved_at.isoformat() if j.approved_at else None
return data
@router.put("/{session_id}/journal/entry-no")
def set_journal_entry_no(session_id: int, entry_no: str = Body(..., embed=True),
db: OrmSession = Depends(db_dep)) -> dict:
ensure_editable(get_session_or_404(session_id, db))
j = db.query(models.JournalEntry).filter(
models.JournalEntry.session_id == session_id).first()
if j:
j.entry_no = entry_no
db.commit()
return {"entry_no": entry_no}
def _journal_row_or_400(session_id: int, db: OrmSession) -> models.JournalEntry:
j = db.query(models.JournalEntry).filter(
models.JournalEntry.session_id == session_id).first()
if j is None or not j.data:
raise HTTPException(400, "Process the closing before signing off its journal entry.")
return j
@router.post("/{session_id}/journal/review")
def review_journal(session_id: int, request: Request, name: str = Body("", embed=True),
db: OrmSession = Depends(db_dep)) -> dict:
"""Step 1 of the sign-off: a person confirms they reviewed this month's journal.
The signed-in user's display name is recorded; the body `name` only counts when no
one is signed in (auth off — dev and tests)."""
ensure_editable(get_session_or_404(session_id, db))
who = actor_name(request, name)
if not who:
raise HTTPException(400, "A reviewer name is required.")
j = _journal_row_or_400(session_id, db)
j.reviewed_by = who
j.reviewed_at = dt.datetime.utcnow()
db.commit()
return journal(session_id, None, db)
@router.post("/{session_id}/journal/approve")
def approve_journal(session_id: int, request: Request, name: str = Body("", embed=True),
db: OrmSession = Depends(db_dep)) -> dict:
"""Step 2: approval — this is what publishes the month to the Accounts Summary.
Requires a prior review, and a closing that isn't blocked by a month-end control:
an unverified number must never become part of the cross-month accounts view.
The signed-in user's display name is recorded (body `name` only without auth)."""
s = get_session_or_404(session_id, db)
ensure_editable(s)
who = actor_name(request, name)
if not who:
raise HTTPException(400, "An approver name is required.")
if is_blocked(s):
raise HTTPException(409, f"This closing is blocked by a failed month-end control — "
f"{s.blocked_reason}")
j = _journal_row_or_400(session_id, db)
if not j.reviewed_by:
raise HTTPException(400, "The journal must be reviewed before it can be approved.")
j.approved_by = who
j.approved_at = dt.datetime.utcnow()
db.commit()
return journal(session_id, None, db)
@router.post("/{session_id}/journal/reset-signoff")
def reset_journal_signoff(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
"""Withdraw the sign-off (removes the month from the Accounts Summary)."""
ensure_editable(get_session_or_404(session_id, db))
j = _journal_row_or_400(session_id, db)
j.reviewed_by = ""
j.reviewed_at = None
j.approved_by = ""
j.approved_at = None
db.commit()
return journal(session_id, None, db)
@router.get("/{session_id}/aging")
def aging(session_id: int, scheme: str = "monthly",
db: OrmSession = Depends(db_dep)) -> dict:
"""
Real aging, banded by days **past due** — not days since the transaction.
An Amazon receivable is not due when the order posts; it is due when its settlement
disburses. Amazon closes a settlement roughly every 14 days and the payout then takes the
clearing lag to land, so a settlement is only overdue once
`last activity + SETTLEMENT_CYCLE_DAYS + clearing_lag` has passed.
A healthy month therefore lands ~100% in Current, matching the Finance workbook. What
changes is that a settlement Amazon is actually holding — dispute, account review, stuck
disbursement — now ages into 1-30/31-60/61-90 instead of hiding inside Current, which is
the entire point of an aging report. (Banding by transaction date instead would push a
normal biweekly settlement into 1-30 and make the report meaningless.)
"""
if scheme not in AGING_SCHEMES:
raise HTTPException(400, f"scheme must be one of: {', '.join(AGING_SCHEMES)}.")
s = get_session_or_404(session_id, db)
if is_blocked(s):
return blocked_payload(s)
bands = aging_bands(scheme)
rows = db.query(models.ReceivableResultRow).filter(
models.ReceivableResultRow.session_id == session_id,
models.ReceivableResultRow.account_type == "TOTAL").all()
setts = db.query(models.Settlement).filter(
models.Settlement.session_id == session_id,
models.Settlement.status == "receivable").all()
month_end = s.month_end_date
# Local-currency band composition from the settlements that make up the receivable.
by_mkt: dict[str, dict[str, float]] = {}
lag = s.clearing_lag_days or 0
for st in setts:
if (st.account_type or "").strip().lower() not in RECEIVABLE_ACCOUNT_TYPES:
continue # transfers / unspecified aren't receivable
if month_end and st.last_date:
due = st.last_date + dt.timedelta(days=SETTLEMENT_CYCLE_DAYS + lag)
days_overdue = (month_end - due).days
else:
days_overdue = 0
band = classify_aging(days_overdue, scheme)
by_mkt.setdefault(st.marketplace, {b: 0.0 for b in bands})[band] += st.order_total
matrix = []
for r in rows:
local_bands = by_mkt.get(r.marketplace) or {b: 0.0 for b in bands}
composed = sum(local_bands.values())
# The receivable is ROUND(reserve + additional sales); the reserve and that rounding
# belong to the current period, so the residual lands in Current and the row still
# ties exactly to the headline receivable.
residual = (r.receivable_local or 0.0) - composed
rate = r.fx_rate or 1.0
band_usd = {b: round(v * rate, 2) for b, v in local_bands.items()}
band_usd["Current"] = round((local_bands["Current"] + residual) * rate, 2)
total = round(sum(band_usd.values()), 2)
matrix.append({"marketplace": r.marketplace, "currency": r.currency,
**band_usd, "Total": total})
return {"bands": list(bands), "scheme": scheme, "rows": matrix,
"basis": (f"days past due at month-end — a settlement becomes due "
f"{SETTLEMENT_CYCLE_DAYS} days after its last activity plus the "
f"{lag}-day clearing lag")}