""" Bank receipts for Amazon payouts. Amazon's Transfer row is dated when the payout was INITIATED; the money reaches the bank 3-5 working days later. Finance records the actual bank date (and amount) per payout here, and that record — not the transfer date — decides received vs in-transit: received ⇔ bank_date ≤ month-end payout_mode: auto (default) a payout without a receipt falls back to the clearing-lag heuristic manual a payout without a receipt is NOT received — no heuristic at all Changing receipts or the mode only takes effect when the closing is re-processed (the classification is computed during processing); until then the session carries `needs_reprocess` and the UI shows a banner. """ from __future__ import annotations import datetime as dt from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from sqlalchemy import func from sqlalchemy.orm import Session as OrmSession from ...db import models from ..auth import actor_name from ..deps import db_dep, ensure_editable, get_session_or_404 router = APIRouter(prefix="/api/sessions", tags=["payouts"]) TRANSFER = "Transfer" @router.get("/{session_id}/payouts") def list_payouts(session_id: int, marketplace: str | None = None, db: OrmSession = Depends(db_dep)) -> dict: """ Every Amazon payout in the uploaded files, joined with its bank receipt (if entered). One row per (marketplace, account stream, settlement id) — the same key the engine classifies on. `amazon_date` is when Amazon initiated the payout; `bank_date` is when Finance recorded it as received. """ s = get_session_or_404(session_id, db) q = db.query( models.Transaction.marketplace, models.Transaction.account_type, models.Transaction.settlement_id, func.max(models.Transaction.posted_date), func.sum(models.Transaction.total), func.count(), ).filter( models.Transaction.session_id == session_id, models.Transaction.txn_type_en == TRANSFER, ) if marketplace: q = q.filter(models.Transaction.marketplace == marketplace) q = q.group_by(models.Transaction.marketplace, models.Transaction.account_type, models.Transaction.settlement_id) receipts = {(r.marketplace, r.account_type, r.settlement_id): r for r in db.query(models.PayoutReceipt).filter( models.PayoutReceipt.session_id == session_id)} # Classification as of the last processing run (what the ledger currently uses). status = {(r.marketplace, r.account_type, r.settlement_id): r.transfer_received for r in db.query(models.Settlement).filter( models.Settlement.session_id == session_id) if r.transfer_received is not None} month_end = s.month_end_date out = [] for mkt, acct, sid, amazon_date, amount, n in q: rec = receipts.get((mkt, acct, sid)) # What the receipt implies for the NEXT processing run. if rec is not None: will_receive = bool(rec.bank_date and month_end and rec.bank_date <= month_end) elif (s.payout_mode or "auto") == "manual": will_receive = False else: cutoff = (month_end - dt.timedelta(days=s.clearing_lag_days or 0) if month_end else None) d = amazon_date if isinstance(amazon_date, dt.date) else ( dt.date.fromisoformat(str(amazon_date)) if amazon_date else None) will_receive = bool(d and cutoff and d <= cutoff) out.append({ "marketplace": mkt, "account_type": acct, "settlement_id": sid, "amazon_date": str(amazon_date) if amazon_date else None, "amount": round(amount or 0.0, 2), "rows": n, "bank_date": rec.bank_date.isoformat() if rec and rec.bank_date else None, "bank_amount": rec.bank_amount if rec else None, "note": (rec.note if rec else "") or "", "entered_by": (rec.entered_by if rec else "") or "", "received_now": status.get((mkt, acct, sid)), "received_next_run": will_receive, }) out.sort(key=lambda r: (r["marketplace"], r["amazon_date"] or "", r["settlement_id"])) return { "payout_mode": s.payout_mode or "auto", "clearing_lag_days": s.clearing_lag_days, "month_end": month_end.isoformat() if month_end else None, "needs_reprocess": bool(s.needs_reprocess), "payouts": out, } class ReceiptIn(BaseModel): marketplace: str account_type: str settlement_id: str bank_date: str | None = None # None/"" removes the receipt bank_amount: float | None = None note: str = "" entered_by: str = "" @router.put("/{session_id}/payouts/receipts") def put_receipts(session_id: int, items: list[ReceiptIn], request: Request, db: OrmSession = Depends(db_dep)) -> dict: """Batch upsert bank receipts. Only the payouts sent are touched; a null bank_date deletes that payout's receipt (it reverts to the mode's default rule).""" s = get_session_or_404(session_id, db) ensure_editable(s) if s.status == "processing": raise HTTPException(409, "This closing is still processing — wait for it to finish.") existing = {(r.marketplace, r.account_type, r.settlement_id): r for r in db.query(models.PayoutReceipt).filter( models.PayoutReceipt.session_id == session_id)} saved = removed = 0 for it in items: key = (it.marketplace, it.account_type, it.settlement_id) row = existing.get(key) if not it.bank_date: if row is not None: db.delete(row) removed += 1 continue try: bank_date = dt.date.fromisoformat(it.bank_date) except ValueError: raise HTTPException(400, f"bank_date must be YYYY-MM-DD (got {it.bank_date!r}).") if row is None: row = models.PayoutReceipt( session_id=session_id, marketplace=it.marketplace, account_type=it.account_type, settlement_id=it.settlement_id) db.add(row) row.bank_date = bank_date row.bank_amount = it.bank_amount row.note = it.note or "" # The signed-in user's name wins; the free-text field only counts without auth. row.entered_by = actor_name(request, it.entered_by) saved += 1 if saved or removed: # The stored classification no longer reflects the receipts until a re-process. s.needs_reprocess = True db.commit() return {"saved": saved, "removed": removed, "needs_reprocess": bool(s.needs_reprocess)} class ModeIn(BaseModel): mode: str @router.put("/{session_id}/payouts/mode") def put_mode(session_id: int, body: ModeIn, db: OrmSession = Depends(db_dep)) -> dict: """auto = bank date wins, clearing-lag fallback · manual = bank dates only, no heuristic.""" s = get_session_or_404(session_id, db) ensure_editable(s) if body.mode not in ("auto", "manual"): raise HTTPException(400, "mode must be 'auto' or 'manual'.") if s.status == "processing": raise HTTPException(409, "This closing is still processing — wait for it to finish.") if (s.payout_mode or "auto") != body.mode: s.payout_mode = body.mode s.needs_reprocess = True db.commit() return {"payout_mode": s.payout_mode, "needs_reprocess": bool(s.needs_reprocess)}