"""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 from sqlalchemy import func from sqlalchemy.orm import Session as OrmSession from ...core.receivable import AGING_BANDS, classify_aging from ...core.settlements import RECEIVABLE_ACCOUNT_TYPES from ...db import models from ..deps import blocked_payload, db_dep, 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: 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, 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.""" get_session_or_404(session_id, db) if not name.strip(): raise HTTPException(400, "A reviewer name is required.") j = _journal_row_or_400(session_id, db) j.reviewed_by = name.strip() 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, 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.""" s = get_session_or_404(session_id, db) if not name.strip(): 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 = name.strip() 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).""" 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, 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.) """ s = get_session_or_404(session_id, db) if is_blocked(s): return blocked_payload(s) 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) by_mkt.setdefault(st.marketplace, {b: 0.0 for b in AGING_BANDS})[band] += st.order_total matrix = [] for r in rows: local_bands = by_mkt.get(r.marketplace) or {b: 0.0 for b in AGING_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(AGING_BANDS), "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")}