""" Accounts Summary — the cross-month view of APPROVED journal entries. One row per (month, marketplace): the journal's GL lines (accrual presentation — Transfer excluded, Receivable = net revenue Dr A/R) in local currency plus the session's FX rate, so the UI can show any marketplace across every month, or all marketplaces converted to USD. Only months whose journal has been APPROVED appear — approval on the Journal Entry tab is the publish step. A month drops out again if its sign-off is withdrawn, if it is re-processed (the sign-off clears with the numbers), or if a month-end control blocks it. """ from __future__ import annotations import json from fastapi import APIRouter, Depends from sqlalchemy.orm import Session as OrmSession from ...core.journal import ACCRUAL_DISPLAY_EXCLUDES, LINE_ACCOUNTS, RECEIVABLE_KEY from ...db import models from ..deps import db_dep, is_blocked router = APIRouter(prefix="/api", tags=["accounts-summary"]) @router.get("/accounts-summary") def accounts_summary(db: OrmSession = Depends(db_dep)) -> dict: from .ar import _journal_for, _market_list, fx_for entries = ( db.query(models.JournalEntry, models.Session) .join(models.Session, models.Session.id == models.JournalEntry.session_id) .filter(models.JournalEntry.approved_by != "") .order_by(models.Session.reporting_month, models.Session.id) .all() ) line_keys = [k for k, _ in LINE_ACCOUNTS if k not in ACCRUAL_DISPLAY_EXCLUDES] months: list[dict] = [] cells: list[dict] = [] marketplaces: set[str] = set() for j, s in entries: if is_blocked(s) or not j.data: continue # a blocked closing publishes nothing payload = json.loads(j.data) month = s.reporting_month or (s.month_end_date.isoformat()[:7] if s.month_end_date else f"session-{s.id}") months.append({ "month": month, "session_id": s.id, "session_name": s.name, "reviewed_by": j.reviewed_by or "", "approved_by": j.approved_by or "", "approved_at": j.approved_at.isoformat() if j.approved_at else None, "entry_no": j.entry_no or "", }) for mkt in _market_list(payload): sub, _ = _journal_for(payload, mkt) rate, currency = fx_for(db, s.id, mkt) values = {ln["key"]: ln["total"] for ln in sub.get("lines", []) if ln["key"] not in ACCRUAL_DISPLAY_EXCLUDES} accrual = sub.get("receivable_accrual") if accrual is None: # Payload stored before the accrual figure existed: derive it. accrual_total = -round(sum(values.values()), 2) else: accrual_total = accrual["total"] marketplaces.add(mkt) cells.append({ "month": month, "session_id": s.id, "marketplace": mkt, "currency": currency, "fx_rate": rate, "values": values, # line key -> local-currency total "receivable": accrual_total, # Dr A/R (net revenue accrued) }) # Months that HAVE results but are not published, so they don't just vanish from this # view without a word (the #1 "my previous month disappeared" confusion): processed or # blocked closings whose journal is not approved — including sign-offs cleared by a # re-process — are listed with the reason and a link target. published_ids = {m["session_id"] for m in months} pending: list[dict] = [] candidates = db.query(models.Session).filter( models.Session.status.in_(("processed", "blocked", "completed"))).all() journals = {j.session_id: j for j in db.query(models.JournalEntry).filter( models.JournalEntry.session_id.in_([s.id for s in candidates]))} if candidates else {} for s in candidates: if s.id in published_ids: continue j = journals.get(s.id) if is_blocked(s): reason = f"blocked by a failed month-end control — {s.blocked_reason}" elif j is None or not j.data: reason = "no journal entry yet — re-process the closing" elif j.approved_by: reason = "approved, but the closing is blocked or has no journal data" elif j.entry_no and not j.reviewed_by: # An entry number exists but both sign-offs are empty: the usual cause is a # re-process, which deliberately withdraws review/approval. reason = ("sign-off was cleared (typically by re-processing) — " "review and approve the journal again to re-publish") else: reason = "journal not approved yet — approval is what publishes a month here" pending.append({ "month": s.reporting_month or (s.month_end_date.isoformat()[:7] if s.month_end_date else f"session-{s.id}"), "session_id": s.id, "session_name": s.name, "reason": reason, }) pending.sort(key=lambda p: p["month"]) return { "available": bool(months), "line_keys": line_keys, "receivable_key": RECEIVABLE_KEY, "months": months, "marketplaces": sorted(marketplaces), "cells": cells, "pending": pending, }