178 lines
7.3 KiB
Python
178 lines
7.3 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
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session as OrmSession
|
|
|
|
from ...db import models
|
|
from ..deps import db_dep, 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],
|
|
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)
|
|
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 ""
|
|
row.entered_by = it.entered_by or ""
|
|
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)
|
|
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)}
|