"""Opening AR balances (manual + carry-forward) and the AR roll-forward / ledger.""" from __future__ import annotations import json from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session as OrmSession from ...core.i18n import currency_for_region, default_fx_for_region from ...core.movement import compute_movement from ...db import models from ..deps import (blocked_payload, db_dep, ensure_editable, get_session_or_404, is_blocked, to_dict) router = APIRouter(prefix="/api/sessions", tags=["ar"]) class OpeningIn(BaseModel): marketplace: str amount: float = 0.0 reason: str = "" source: str = "manual" def _marketplaces(db: OrmSession, session_id: int) -> list[str]: rows = db.query(models.ReceivableResultRow.marketplace).filter( models.ReceivableResultRow.session_id == session_id).distinct().all() return [r[0] for r in rows] or ["USA"] @router.get("/{session_id}/opening-balances") def get_openings(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]: get_session_or_404(session_id, db) rows = {o.marketplace: o for o in db.query(models.OpeningBalance).filter( models.OpeningBalance.session_id == session_id)} out = [] for mkt in _marketplaces(db, session_id): o = rows.get(mkt) out.append({"marketplace": mkt, "amount": o.amount if o else 0.0, "reason": o.reason if o else "", "source": o.source if o else "manual"}) # include any openings for marketplaces not (yet) in results for mkt, o in rows.items(): if mkt not in [x["marketplace"] for x in out]: out.append({"marketplace": mkt, "amount": o.amount, "reason": o.reason, "source": o.source}) return out @router.put("/{session_id}/opening-balances") def put_openings(session_id: int, items: list[OpeningIn], db: OrmSession = Depends(db_dep)) -> list[dict]: """Set one or more marketplaces' opening balances (only the ones sent are touched).""" s = get_session_or_404(session_id, db) ensure_editable(s) existing = {o.marketplace: o for o in db.query(models.OpeningBalance).filter( models.OpeningBalance.session_id == session_id)} for it in items: o = existing.get(it.marketplace) if o is None: o = models.OpeningBalance(session_id=session_id, marketplace=it.marketplace) db.add(o) o.amount = it.amount o.reason = it.reason o.source = it.source or "manual" if items: s.opening_mode = "manual" db.commit() _revalidate(db, session_id, s) return get_openings(session_id, db) def _revalidate(db: OrmSession, session_id: int, s: models.Session) -> None: """Opening balances feed the roll-forward, so control C4 has to be re-evaluated.""" if s.status in ("processed", "blocked", "completed"): from ...services.controls_run import run_and_persist run_and_persist(db, session_id) def _market_list(journal: dict) -> list[str]: """Marketplaces available in a stored journal payload (primary first, then alphabetical).""" primary = journal.get("marketplace") included = list(journal.get("marketplaces_included") or (journal.get("per_marketplace") or {})) if primary and primary not in included: included.append(primary) rest = sorted(m for m in included if m != primary) return ([primary] if primary else []) + rest def _journal_for(journal: dict, marketplace: str | None) -> tuple[dict, str]: """Resolve the requested marketplace's journal (defaults to the primary one).""" primary = journal.get("marketplace", "USA") if not marketplace or marketplace == primary: return journal, primary per = journal.get("per_marketplace") or {} sub = per.get(marketplace) if sub is None: return journal, primary return sub, marketplace def fx_for(db: OrmSession, session_id: int, marketplace: str) -> tuple[float, str]: """ (rate_to_usd, currency) for a marketplace — the single shared resolution used by every surface that converts. Never falls back to a bare "USD": an unknown marketplace resolves its currency from the marketplace config, so EUR amounts can't render labelled USD. """ row = db.query(models.FxRate).filter( models.FxRate.session_id == session_id, models.FxRate.marketplace == marketplace).first() if row is not None: return (row.rate if row.rate is not None else default_fx_for_region(marketplace), row.currency or currency_for_region(marketplace)) return default_fx_for_region(marketplace), currency_for_region(marketplace) def _payouts_for(db: OrmSession, session_id: int, marketplace: str, markets: list[str]) -> tuple[float, float]: """Per-marketplace payouts; falls back to session totals for pre-upgrade single-market runs.""" row = db.query(models.MarketPayout).filter( models.MarketPayout.session_id == session_id, models.MarketPayout.marketplace == marketplace).first() if row is not None: return row.received_payouts or 0.0, row.all_payouts or 0.0 any_rows = db.query(models.MarketPayout).filter( models.MarketPayout.session_id == session_id).first() if any_rows is None and len(markets) <= 1: recon = db.query(models.ReconciliationRow).filter( models.ReconciliationRow.session_id == session_id).first() if recon: return recon.received_payouts or 0.0, recon.all_payouts or 0.0 return 0.0, 0.0 def _movement_for(db: OrmSession, session_id: int, marketplace: str | None) -> dict: 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) journal, mkt = _journal_for(payload, marketplace) opening_row = db.query(models.OpeningBalance).filter( models.OpeningBalance.session_id == session_id, models.OpeningBalance.marketplace == mkt).first() settlement = db.query(models.ReceivableResultRow).filter( models.ReceivableResultRow.session_id == session_id, models.ReceivableResultRow.marketplace == mkt, models.ReceivableResultRow.account_type == "TOTAL").first() received, all_p = _payouts_for(db, session_id, mkt, markets) rate, currency = fx_for(db, session_id, mkt) mv = compute_movement( journal, received_payouts=received, all_payouts=all_p, opening=opening_row.amount if opening_row else 0.0, settlement_closing=round(settlement.receivable_local) if settlement else None, currency=(settlement.currency if settlement else currency), ) mv["available"] = True mv["marketplace"] = mkt mv["marketplaces"] = markets # Every figure in `mv` is LOCAL currency. Callers that roll several marketplaces together # must convert with this rate — never add the locals (see core/money.Total). mv["fx_rate"] = rate mv["journal"] = journal mv["opening_source"] = opening_row.source if opening_row else "manual" mv["opening_reason"] = opening_row.reason if opening_row else "" return mv @router.get("/{session_id}/ar-movement") def ar_movement(session_id: int, marketplace: str | None = None, db: OrmSession = Depends(db_dep)) -> dict: get_session_or_404(session_id, db) mv = _movement_for(db, session_id, marketplace) mv.pop("journal", None) return mv def build_finance_summary(db: OrmSession, session_id: int, marketplace: str | None = None) -> dict: """The Finance summary report: opening → revenue components → fees → net → payout → closing.""" from ...core.journal import GROSS_KEYS s = db.get(models.Session, session_id) mv = _movement_for(db, session_id, marketplace) if not mv.get("available"): return {"available": False} journal = mv.pop("journal") mkt = mv["marketplace"] comps = {c["key"]: c for c in journal.get("components", [])} gross = round(sum(comps[k]["total"] for k in GROSS_KEYS if k in comps), 2) fc = db.query(models.FinanceControl).filter( models.FinanceControl.session_id == session_id).first() finance_closing = fc.closing_receivable if fc else None tol = (fc.tolerance if fc and fc.tolerance is not None else 1.0) difference = round(mv["closing"] - finance_closing, 2) if finance_closing is not None else None if difference is None: status = "pending" elif abs(difference) <= tol: status = "matched" else: status = "review" return { "available": True, "marketplace": mkt, "marketplaces": mv["marketplaces"], "currency": mv["currency"], "reporting_month": s.reporting_month if s else "", "month_end": s.month_end_date.isoformat() if s and s.month_end_date else "", "period_labels": mv["period_labels"], "opening_balance": mv["opening"], "components": journal.get("components", []), "gross_revenue": gross, "net_revenue": mv["net_revenue"], "disbursements": mv["received_payouts"], "in_transit_payouts": mv["in_transit_payouts"], "closing_receivable": mv["closing"], "settlement_closing": mv["settlement_closing"], "finance_closing": finance_closing, "difference": difference, "status": status, "verified_by": fc.verified_by if fc else "", "verified_at": fc.verified_at.isoformat() if fc and fc.verified_at else None, "ledger": mv["ledger"], } @router.get("/{session_id}/finance-summary") def finance_summary(session_id: int, marketplace: str | None = None, db: OrmSession = Depends(db_dep)) -> dict: s = get_session_or_404(session_id, db) if is_blocked(s): return blocked_payload(s) return build_finance_summary(db, session_id, marketplace) def _closings_of(db: OrmSession, session_id: int) -> list[dict]: """Per-marketplace closing receivable of a processed session (what carries forward).""" out = [] for mkt in _market_list(_journal_payload(db, session_id)): mv = _movement_for(db, session_id, mkt) if not mv.get("available"): continue mv.pop("journal", None) out.append({"marketplace": mkt, "amount": mv["closing"], "currency": mv["currency"]}) return out def _journal_payload(db: OrmSession, session_id: int) -> dict: j = db.query(models.JournalEntry).filter( models.JournalEntry.session_id == session_id).first() return json.loads(j.data) if j and j.data else {} @router.get("/{session_id}/opening-candidates") def opening_candidates(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: """Processed closings whose closing balance can be carried into this one.""" s = get_session_or_404(session_id, db) q = db.query(models.Session).filter( models.Session.id != session_id, models.Session.status.in_(("processed", "completed"))) if s.reporting_month: q = q.filter(models.Session.reporting_month <= s.reporting_month) rows = q.order_by(models.Session.reporting_month.desc(), models.Session.created_at.desc()).limit(24).all() out = [] for prior in rows: closings = _closings_of(db, prior.id) if not closings: continue out.append({ "session_id": prior.id, "name": prior.name, "reporting_month": prior.reporting_month, "month_end": prior.month_end_date.isoformat() if prior.month_end_date else None, "markets": closings, }) return {"current_mode": s.opening_mode or "zero", "current_source": s.opening_source_session_id, "candidates": out} @router.get("/{session_id}/opening-balances/worksheet") def opening_worksheet(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: """ Every marketplace's opening balance in one place, with the effect of changing it. The closing receivable is computed two independent ways and they must agree: roll-forward = opening + net revenue - payouts received settlement = ROUND(reserve + additional sales) The opening is the only unknown in the roll-forward, so this returns, per marketplace, the current opening, the resulting variance against the settlement method, and the `implied` opening that would close that variance exactly. `implied` is a diagnostic, not an answer: adopting it forces agreement and would make control C4 pass by construction. The correct opening is the prior month's closing balance — carried forward from a processed closing, or typed from the prior workbook. """ s = get_session_or_404(session_id, db) payload = _journal_payload(db, session_id) markets = _market_list(payload) if not markets: return {"available": False} openings = {o.marketplace: o for o in db.query(models.OpeningBalance).filter( models.OpeningBalance.session_id == session_id)} rows, total_variance = [], 0.0 for mkt in markets: mv = _movement_for(db, session_id, mkt) if not mv.get("available"): continue mv.pop("journal", None) o = openings.get(mkt) settlement = mv.get("settlement_closing") # closing = opening + net_revenue + received_payouts (payouts are stored negative) movement = round(mv["net_revenue"] + mv["received_payouts"], 2) implied = round(settlement - movement, 2) if settlement is not None else None variance = mv.get("difference_vs_settlement") rate, currency = fx_for(db, session_id, mkt) rows.append({ "marketplace": mkt, "currency": currency, "fx_rate": rate, "opening": round(o.amount, 2) if o else 0.0, "source": (o.source if o else "zero") or "zero", "reason": (o.reason if o else "") or "", "net_revenue": mv["net_revenue"], "payouts_received": mv["received_payouts"], "movement": movement, "roll_forward_closing": mv["closing"], "settlement_closing": settlement, "variance": variance, "implied_opening": implied, "reconciled": variance is not None and abs(variance) < 1.0, }) if variance: total_variance += abs(variance) * (rate or 1.0) cands = opening_candidates(session_id, db) return { "available": True, "reporting_month": s.reporting_month or "", "mode": s.opening_mode or "zero", "source_session_id": s.opening_source_session_id, "rows": rows, "all_zero": all(r["opening"] == 0.0 for r in rows), "unreconciled": sum(1 for r in rows if not r["reconciled"]), "total_abs_variance_usd": round(total_variance, 2), "candidates": cands["candidates"], } class CarryForwardIn(BaseModel): from_session_id: int | None = None # defaults to the most recent prior closing @router.post("/{session_id}/opening-balances/carry-forward") def carry_forward(session_id: int, body: CarryForwardIn | None = None, db: OrmSession = Depends(db_dep)) -> dict: """Copy a prior closing's per-marketplace closing balance into this closing's opening.""" s = get_session_or_404(session_id, db) ensure_editable(s) src_id = (body.from_session_id if body else None) or s.opening_source_session_id if src_id is None: cands = opening_candidates(session_id, db)["candidates"] if not cands: raise HTTPException(400, "No processed prior closing is available to carry forward.") src_id = cands[0]["session_id"] prior = db.get(models.Session, src_id) if prior is None: raise HTTPException(404, "Source closing not found.") closings = _closings_of(db, src_id) if not closings: raise HTTPException(400, f"'{prior.name}' has no closing balances to carry forward.") existing = {o.marketplace: o for o in db.query(models.OpeningBalance).filter( models.OpeningBalance.session_id == session_id)} label = prior.reporting_month or prior.name for c in closings: o = existing.get(c["marketplace"]) if o is None: o = models.OpeningBalance(session_id=session_id, marketplace=c["marketplace"]) db.add(o) o.amount = c["amount"] o.source = "carried_forward" o.reason = f"Carried forward from {label} closing" s.opening_mode = "carry_forward" s.opening_source_session_id = src_id db.commit() _revalidate(db, session_id, s) return {"applied": len(closings), "from_session_id": src_id, "from": prior.name, "balances": get_openings(session_id, db)} @router.post("/{session_id}/opening-balances/reset") def reset_openings(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: """Set every opening balance to zero (the default for a first-ever closing).""" s = get_session_or_404(session_id, db) ensure_editable(s) for o in db.query(models.OpeningBalance).filter( models.OpeningBalance.session_id == session_id): o.amount = 0.0 o.source = "zero" o.reason = "Opening balance set to zero" s.opening_mode = "zero" s.opening_source_session_id = None db.commit() _revalidate(db, session_id, s) return {"balances": get_openings(session_id, db)} def seed_opening_from_prior(db: OrmSession, new_session: models.Session) -> None: """Apply the chosen opening-balance mode when a closing is created. zero (default) / manual -> nothing is seeded (every marketplace starts at 0 and the user types values on the AR Ledger tab). carry_forward -> copy the prior closing's per-marketplace closing balance. """ if (new_session.opening_mode or "zero") != "carry_forward": return try: carry_forward(new_session.id, CarryForwardIn(from_session_id=new_session.opening_source_session_id), db) except HTTPException: # No usable prior closing yet — fall back to zero rather than blocking creation. new_session.opening_mode = "zero" db.commit()