""" 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, File, HTTPException, Request, UploadFile from pydantic import BaseModel from sqlalchemy import func from sqlalchemy.orm import Session as OrmSession from ...config import MAX_UPLOAD_BYTES from ...core.bank_import import BankImportError, match_payouts, parse_disbursements from ...core.i18n import currency_for_region 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" def _payout_rows(db: OrmSession, session_id: int, marketplace: str | None = None): """One row per (marketplace, account stream, settlement id) — the key the engine classifies on: (mkt, acct, sid, max(posted_date), sum(total), count).""" 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) return q.group_by(models.Transaction.marketplace, models.Transaction.account_type, models.Transaction.settlement_id) @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 = _payout_rows(db, session_id, marketplace) 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)} @router.post("/{session_id}/payouts/receipts/import") async def import_receipts(session_id: int, file: UploadFile = File(...), window_days: int = 14, db: OrmSession = Depends(db_dep)) -> dict: """ Parse a bank disbursements workbook (sheet 'Payouts': Date/Debit/FCY/Party Name…) and propose bank receipts for this closing's payouts. READ-ONLY: nothing is saved — the client applies accepted matches through PUT /payouts/receipts, which keeps that endpoint's semantics (upsert, needs_reprocess, entered_by) in one place. """ s = get_session_or_404(session_id, db) name = (file.filename or "").lower() if not name.endswith((".xlsx", ".xls")): raise HTTPException(400, "Upload the bank disbursements Excel file (.xlsx).") if not 1 <= window_days <= 60: raise HTTPException(400, "window_days must be between 1 and 60.") data = await file.read() if len(data) > MAX_UPLOAD_BYTES: raise HTTPException(400, "File too large.") try: rows, problems = parse_disbursements(data) except BankImportError as e: raise HTTPException(400, str(e)) def _date(v) -> dt.date | None: if isinstance(v, dt.datetime): return v.date() if isinstance(v, dt.date): return v try: return dt.date.fromisoformat(str(v)[:10]) if v else None except ValueError: return None payouts = [ {"marketplace": mkt, "account_type": acct, "settlement_id": sid, "amazon_date": _date(d), "amount": round(amount or 0.0, 2)} for mkt, acct, sid, d, amount, _n in _payout_rows(db, session_id) ] receipts = {(r.marketplace, r.account_type, r.settlement_id): r.bank_date for r in db.query(models.PayoutReceipt).filter( models.PayoutReceipt.session_id == session_id)} # Session FX rows carry the marketplace's currency (confirmed by Finance); fall back # to the built-in region -> currency table. currencies = {mkt: currency_for_region(mkt) for (mkt,) in db.query(models.Transaction.marketplace).filter( models.Transaction.session_id == session_id).distinct()} for fx in db.query(models.FxRate).filter(models.FxRate.session_id == session_id): if fx.marketplace and fx.currency: currencies[fx.marketplace] = fx.currency m = match_payouts(rows, payouts, s.month_end_date, window_days=window_days, receipts=receipts, currency_by_marketplace=currencies) return { "total_rows": len(rows), "window_days": window_days, "matched": m.matched, "ambiguous": m.ambiguous, "unmatched_bank_rows": m.unmatched, "unknown_party": m.unknown_party, "out_of_scope": m.out_of_scope, "problems": problems + m.problems, } 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)}