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

325 lines
13 KiB
Python

"""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()