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

182 lines
7.5 KiB
Python

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