"""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.movement import compute_movement from ...db import models from ..deps import db_dep, get_session_or_404, 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]: get_session_or_404(session_id, db) 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" db.commit() return get_openings(session_id, db) 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 _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) 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 "USD"), ) mv["available"] = True mv["marketplace"] = mkt mv["marketplaces"] = markets 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: get_session_or_404(session_id, db) 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} 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) 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() 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) 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() 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()