257 lines
11 KiB
Python
257 lines
11 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, 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)}
|