diff --git a/ar-aging-app/backend/app/api/routes/payouts.py b/ar-aging-app/backend/app/api/routes/payouts.py index 9d9024d..2b87002 100644 --- a/ar-aging-app/backend/app/api/routes/payouts.py +++ b/ar-aging-app/backend/app/api/routes/payouts.py @@ -19,11 +19,14 @@ from __future__ import annotations import datetime as dt -from fastapi import APIRouter, Depends, HTTPException, Request +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 @@ -33,17 +36,9 @@ 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) +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, @@ -57,8 +52,22 @@ def list_payouts(session_id: int, marketplace: str | None = None, ) if marketplace: q = q.filter(models.Transaction.marketplace == marketplace) - q = q.group_by(models.Transaction.marketplace, models.Transaction.account_type, - models.Transaction.settlement_id) + 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( @@ -161,6 +170,72 @@ def put_receipts(session_id: int, items: list[ReceiptIn], request: Request, 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 diff --git a/ar-aging-app/backend/app/core/bank_import.py b/ar-aging-app/backend/app/core/bank_import.py new file mode 100644 index 0000000..a7dfdf9 --- /dev/null +++ b/ar-aging-app/backend/app/core/bank_import.py @@ -0,0 +1,301 @@ +""" +Bank disbursements import: parse the finance team's bank-deposit workbook and propose +bank receipts for the session's Amazon payouts. + +The workbook (one row per bank credit) looks like: + + Company Link | Type | B. Acc | FCY | Date | Month | Text | Debit | Credit | Net | Party Name + +`Party Name` identifies the marketplace ("Amazon US", "Amazon Germany", ...), `Date` is +the day the money reached the bank, `Debit` the amount credited in the bank account's +currency. Matching is deliberately conservative: a bank row is only auto-matched when it +points at exactly ONE payout; anything else is surfaced as ambiguous/unmatched for a human. + +Currency wrinkle: some deposits arrive converted (Australia payouts land as USD), so the +amount check only runs when the row's FCY equals the marketplace's currency — otherwise +the match is date-only and flagged (`amount_checked: False`). + +Pure module: no ORM, no FastAPI — unit-testable with plain lists/dicts. +""" +from __future__ import annotations + +import datetime as dt +from dataclasses import dataclass, field +from io import BytesIO +from typing import Any + +# Bank narrative party -> engine marketplace label (regions.py). Casefolded lookup keys +# absorb the inconsistent casing seen in real files ("Amazon sweden"). +PARTY_TO_MARKETPLACE: dict[str, str] = { + "amazon us": "USA", "amazon usa": "USA", + "amazon uk": "UK", + "amazon canada": "Canada", + "amazon australia": "Australia", + "amazon germany": "Germany", + "amazon france": "France", + "amazon italy": "Italy", + "amazon spain": "Spain", + "amazon netherlands": "Netherlands", + "amazon belgium": "Belgium", + "amazon ireland": "Ireland", + "amazon poland": "Poland", + "amazon sweden": "Sweden", + "amazon turkey": "Turkey", +} + +_REQUIRED_HEADERS = ("date", "debit", "party name") +_EXCEL_EPOCH = dt.date(1899, 12, 30) + + +class BankImportError(ValueError): + """The uploaded workbook is not a recognizable disbursements file.""" + + +@dataclass +class BankRow: + sheet_row: int # 1-based row in the sheet, for human cross-reference + party: str + marketplace: str | None # None = unknown party + currency: str # FCY column, uppercased ("" if absent) + bank_date: dt.date + narrative: str + debit: float + credit: float + net: float + + +def _as_date(value: Any) -> dt.date | None: + if isinstance(value, dt.datetime): + return value.date() + if isinstance(value, dt.date): + return value + if isinstance(value, (int, float)) and value > 0: # raw Excel serial + return _EXCEL_EPOCH + dt.timedelta(days=float(value)) + if isinstance(value, str): + try: + return dt.date.fromisoformat(value.strip()[:10]) + except ValueError: + return None + return None + + +def _as_float(value: Any) -> float: + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value.replace(",", "").strip() or 0.0) + except ValueError: + return 0.0 + return 0.0 + + +def parse_disbursements(data: bytes) -> tuple[list[BankRow], list[str]]: + """All Deposit rows of the workbook's Payouts sheet, plus per-row problems.""" + from python_calamine import CalamineWorkbook + + try: + wb = CalamineWorkbook.from_filelike(BytesIO(data)) + except Exception as e: # noqa: BLE001 — calamine raises library-specific errors + raise BankImportError(f"Could not open the workbook: {e}") from e + + # The sheet named "Payouts" (any casing), else the first sheet with the right headers. + sheet = None + for name in wb.sheet_names: + if name.strip().casefold() == "payouts": + sheet = name + break + if sheet is None: + for name in wb.sheet_names: + head = wb.get_sheet_by_name(name).to_python(nrows=1) + labels = {str(c).strip().casefold() for c in (head[0] if head else [])} + if all(h in labels for h in _REQUIRED_HEADERS): + sheet = name + break + if sheet is None: + raise BankImportError( + "No disbursements sheet found — expected a sheet named 'Payouts' (or one whose " + "first row has 'Date', 'Debit' and 'Party Name' columns).") + + grid = wb.get_sheet_by_name(sheet).to_python() + if not grid: + raise BankImportError(f"Sheet '{sheet}' is empty.") + header = [str(c).strip().casefold() for c in grid[0]] + col = {label: i for i, label in enumerate(header)} + missing = [h for h in _REQUIRED_HEADERS if h not in col] + if missing: + raise BankImportError(f"Sheet '{sheet}' is missing columns: {', '.join(missing)}.") + + def cell(row: list, label: str) -> Any: + i = col.get(label) + return row[i] if i is not None and i < len(row) else None + + rows: list[BankRow] = [] + problems: list[str] = [] + for idx, raw in enumerate(grid[1:], start=2): + party = str(cell(raw, "party name") or "").strip() + row_type = str(cell(raw, "type") or "").strip() + if not party and not any(str(c).strip() for c in raw): + continue # blank row + if row_type and row_type.casefold() != "deposit": + continue # only bank credits are receipts + bank_date = _as_date(cell(raw, "date")) + if bank_date is None: + problems.append(f"row {idx}: unreadable Date {cell(raw, 'date')!r} — skipped") + continue + if not party: + problems.append(f"row {idx}: empty Party Name — skipped") + continue + debit = _as_float(cell(raw, "debit")) + rows.append(BankRow( + sheet_row=idx, + party=party, + marketplace=PARTY_TO_MARKETPLACE.get(party.casefold()), + currency=str(cell(raw, "fcy") or "").strip().upper(), + bank_date=bank_date, + narrative=str(cell(raw, "text") or "").strip(), + debit=debit if debit else _as_float(cell(raw, "net")), + credit=_as_float(cell(raw, "credit")), + net=_as_float(cell(raw, "net")), + )) + return rows, problems + + +@dataclass +class MatchResult: + matched: list[dict] = field(default_factory=list) + ambiguous: list[dict] = field(default_factory=list) + unmatched: list[dict] = field(default_factory=list) + unknown_party: list[dict] = field(default_factory=list) + out_of_scope: int = 0 + problems: list[str] = field(default_factory=list) + + +def _amount_tolerance(amount: float) -> float: + # Small bank fees / rounding: 0.5% capped from below at 5 cents. + return max(0.05, 0.005 * abs(amount)) + + +def match_payouts( + rows: list[BankRow], + payouts: list[dict], + month_end: dt.date | None, + window_days: int = 14, + receipts: dict[tuple[str, str, str], dt.date] | None = None, + currency_by_marketplace: dict[str, str] | None = None, +) -> MatchResult: + """ + payouts: [{marketplace, account_type, settlement_id, amazon_date: date|None, amount}] + receipts: existing PayoutReceipt bank dates keyed (marketplace, account_type, settlement_id). + Matching is one-to-one: bank rows are processed in (bank_date, sheet_row) order and a + payout consumed by an earlier row is no longer available to later ones. + """ + receipts = receipts or {} + currencies = currency_by_marketplace or {} + result = MatchResult() + + by_marketplace: dict[str, list[dict]] = {} + dated = [] + for p in payouts: + by_marketplace.setdefault(p["marketplace"], []).append(p) + if p.get("amazon_date"): + dated.append(p["amazon_date"]) + scope_start = (min(dated) - dt.timedelta(days=3)) if dated else None + scope_end = (month_end + dt.timedelta(days=window_days)) if month_end else None + + consumed: dict[tuple[str, str, str], int] = {} # payout key -> bank sheet_row that took it + + def key(p: dict) -> tuple[str, str, str]: + return (p["marketplace"], p["account_type"], p["settlement_id"]) + + for row in sorted(rows, key=lambda r: (r.bank_date, r.sheet_row)): + if row.marketplace is None: + result.unknown_party.append({ + "bank_row": row.sheet_row, "party": row.party, + "bank_date": row.bank_date.isoformat(), "amount": row.debit, + }) + continue + + in_scope = (scope_start is None or scope_end is None + or scope_start <= row.bank_date <= scope_end) + + candidates = [] + taken = [] # would match, but already consumed + for p in by_marketplace.get(row.marketplace, []): + d = p.get("amazon_date") + if d is None or not (d <= row.bank_date <= d + dt.timedelta(days=window_days)): + continue + (taken if key(p) in consumed else candidates).append(p) + + amount_checked = bool(row.currency) and currencies.get(row.marketplace) == row.currency + if amount_checked: + confirmed = [p for p in candidates + if abs(row.debit - abs(p["amount"])) <= _amount_tolerance(p["amount"])] + else: + confirmed = [] + + chosen = None + if len(confirmed) == 1: + chosen = confirmed[0] + elif len(confirmed) > 1: + pass # genuinely ambiguous on amount + elif len(candidates) == 1: + chosen = candidates[0] # date-only (fee variance or FX-converted) + + def _cand(p: dict) -> dict: + return {"settlement_id": p["settlement_id"], "account_type": p["account_type"], + "amazon_date": p["amazon_date"].isoformat() if p.get("amazon_date") else None, + "amount": p["amount"]} + + if chosen is not None: + k = key(chosen) + consumed[k] = row.sheet_row + existing = receipts.get(k) + delta = (round(abs(row.debit - abs(chosen["amount"])), 2) + if amount_checked else None) + result.matched.append({ + "marketplace": chosen["marketplace"], + "account_type": chosen["account_type"], + "settlement_id": chosen["settlement_id"], + "amazon_date": chosen["amazon_date"].isoformat() if chosen.get("amazon_date") else None, + "amazon_amount": chosen["amount"], + "bank_date": row.bank_date.isoformat(), + "bank_amount": row.debit, + "currency": row.currency, + "amount_checked": amount_checked, + "delta": delta, + "bank_row": row.sheet_row, + "already_had_receipt": existing is not None, + "existing_bank_date": existing.isoformat() if existing else None, + "note": f"Imported from bank file row {row.sheet_row}" + + ("" if amount_checked else f" ({row.currency} {row.debit:,.2f})"), + }) + continue + + pool = confirmed or candidates + if pool: + result.ambiguous.append({ + "bank_row": row.sheet_row, "party": row.party, + "marketplace": row.marketplace, + "bank_date": row.bank_date.isoformat(), "amount": row.debit, + "reason": f"{len(pool)} payouts match within {window_days} days", + "candidates": [_cand(p) for p in pool], + }) + elif taken: + result.ambiguous.append({ + "bank_row": row.sheet_row, "party": row.party, + "marketplace": row.marketplace, + "bank_date": row.bank_date.isoformat(), "amount": row.debit, + "reason": f"payout already matched by row {consumed[key(taken[0])]}", + "candidates": [_cand(p) for p in taken], + }) + elif not in_scope: + result.out_of_scope += 1 + else: + result.unmatched.append({ + "bank_row": row.sheet_row, "party": row.party, + "marketplace": row.marketplace, + "bank_date": row.bank_date.isoformat(), "amount": row.debit, + "reason": f"no {row.marketplace} payout within {window_days} days before this date", + }) + return result diff --git a/ar-aging-app/backend/tests/test_bank_import.py b/ar-aging-app/backend/tests/test_bank_import.py new file mode 100644 index 0000000..845f4cd --- /dev/null +++ b/ar-aging-app/backend/tests/test_bank_import.py @@ -0,0 +1,227 @@ +""" +Bank disbursements import: parsing the bank workbook, matching deposits to payouts, and +the read-only import endpoint + apply-via-PUT flow. + +Bank fixture mirrors the real file: sheet "Payouts" with + Company Link | Type | B. Acc | FCY | Date | Month | Text | Debit | Credit | Net | Party Name + +Amazon fixture (make_amazon_xlsx, month-end 2026-01-31): USA transfers + sid 200 (Jan 6, -1000, Standard) · sid 300 (Jan 30, -2000, Standard) + sid 250 (Jan 12, -300, Invoiced) +""" +from __future__ import annotations + +import datetime as dt +import os +import tempfile + +import openpyxl +from fastapi.testclient import TestClient + +from app.api.main import app +from app.core.bank_import import BankRow, match_payouts, parse_disbursements +from app.db.database import init_db +from tests.test_excel_export import make_amazon_xlsx + +_TMP = tempfile.mkdtemp(prefix="ar_bank_import_test_") + +_HEADERS = ["Company Link", "Type", "B. Acc", "FCY", "Date", "Month", "Text", + "Debit", "Credit", "Net", "Party Name"] + + +def make_disbursements_xlsx(path: str, rows: list[tuple], sheet: str = "Payouts") -> None: + """rows: (party, fcy, date, debit) or (party, fcy, date, debit, type).""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = sheet + ws.append(_HEADERS) + for r in rows: + party, fcy, date, debit = r[:4] + row_type = r[4] if len(r) > 4 else "Deposit" + ws.append(["Utopia Brands Inc.", row_type, "5887", fcy, date, None, + "ORIG CO NAME=Amazon", debit, 0, debit, party]) + wb.save(path) + + +def _read(path: str) -> bytes: + with open(path, "rb") as fh: + return fh.read() + + +# --------------------------------------------------------------------------- parsing +def test_parse_disbursements_dates_and_types(): + p = os.path.join(_TMP, "parse.xlsx") + make_disbursements_xlsx(p, [ + ("Amazon US", "USD", dt.datetime(2026, 1, 8, 0, 0), 1000.0), + ("Amazon UK", "GBP", # raw Excel serial float + float((dt.date(2026, 1, 13) - dt.date(1899, 12, 30)).days), 250.0), + ("Amazon US", "USD", dt.datetime(2026, 1, 9), 50.0, "Charge"), # non-deposit: skipped + ]) + rows, problems = parse_disbursements(_read(p)) + assert problems == [] + assert [(r.marketplace, r.bank_date, r.debit) for r in rows] == [ + ("USA", dt.date(2026, 1, 8), 1000.0), + ("UK", dt.date(2026, 1, 13), 250.0), + ] + + +def test_parse_rejects_wrong_workbook(): + p = os.path.join(_TMP, "wrong.xlsx") + wb = openpyxl.Workbook() + wb.active.append(["Just", "Some", "Columns"]) + wb.save(p) + try: + parse_disbursements(_read(p)) + assert False, "expected BankImportError" + except ValueError as e: + assert "No disbursements sheet" in str(e) + + +# --------------------------------------------------------------------------- matching +def _row(party, mkt, date, debit, currency="USD", sheet_row=2): + return BankRow(sheet_row=sheet_row, party=party, marketplace=mkt, currency=currency, + bank_date=date, narrative="", debit=debit, credit=0.0, net=debit) + + +_PAYOUT = {"marketplace": "USA", "account_type": "Standard Orders", "settlement_id": "200", + "amazon_date": dt.date(2026, 1, 6), "amount": -1000.0} +_MONTH_END = dt.date(2026, 1, 31) +_CCY = {"USA": "USD", "Sweden": "SEK", "Australia": "AUD"} + + +def test_match_exact_amount_and_window(): + m = match_payouts([_row("Amazon US", "USA", dt.date(2026, 1, 8), 1000.0)], + [_PAYOUT], _MONTH_END, currency_by_marketplace=_CCY) + assert len(m.matched) == 1 and not m.ambiguous and not m.unmatched + got = m.matched[0] + assert got["settlement_id"] == "200" and got["amount_checked"] and got["delta"] == 0.0 + assert got["bank_date"] == "2026-01-08" and not got["already_had_receipt"] + + +def test_party_mapping_case_insensitive_and_unknown(): + sweden = {"marketplace": "Sweden", "account_type": "(unspecified)", "settlement_id": "9", + "amazon_date": dt.date(2026, 1, 10), "amount": -70.0} + rows, _ = parse_disbursements(_read(_mk("party.xlsx", [ + ("Amazon sweden", "SEK", dt.datetime(2026, 1, 12), 70.0), + ("Some Vendor", "USD", dt.datetime(2026, 1, 12), 10.0), + ]))) + m = match_payouts(rows, [sweden], _MONTH_END, currency_by_marketplace=_CCY) + assert len(m.matched) == 1 and m.matched[0]["marketplace"] == "Sweden" + assert len(m.unknown_party) == 1 and m.unknown_party[0]["party"] == "Some Vendor" + + +def _mk(name: str, rows: list[tuple]) -> str: + p = os.path.join(_TMP, name) + make_disbursements_xlsx(p, rows) + return p + + +def test_match_ambiguous_unmatched_and_out_of_scope(): + twin_a = dict(_PAYOUT, settlement_id="201") + twin_b = dict(_PAYOUT, settlement_id="202") + m = match_payouts([ + _row("Amazon US", "USA", dt.date(2026, 1, 8), 1000.0, sheet_row=2), # two equal payouts + _row("Amazon US", "USA", dt.date(2026, 1, 25), 555.0, sheet_row=3), # nothing near + _row("Amazon US", "USA", dt.date(2026, 5, 12), 94.42, sheet_row=4), # other month + ], [twin_a, twin_b], _MONTH_END, currency_by_marketplace=_CCY) + assert not m.matched + assert len(m.ambiguous) == 1 and len(m.ambiguous[0]["candidates"]) == 2 + assert len(m.unmatched) == 1 and m.unmatched[0]["bank_row"] == 3 + assert m.out_of_scope == 1 + + +def test_one_to_one_consumption(): + m = match_payouts([ + _row("Amazon US", "USA", dt.date(2026, 1, 8), 1000.0, sheet_row=2), + _row("Amazon US", "USA", dt.date(2026, 1, 9), 1000.0, sheet_row=3), # same payout again + ], [_PAYOUT], _MONTH_END, currency_by_marketplace=_CCY) + assert len(m.matched) == 1 and m.matched[0]["bank_row"] == 2 + assert len(m.ambiguous) == 1 and "already matched by row 2" in m.ambiguous[0]["reason"] + + +def test_currency_mismatch_matches_by_date_only(): + au = {"marketplace": "Australia", "account_type": "(unspecified)", "settlement_id": "77", + "amazon_date": dt.date(2026, 1, 10), "amount": -140.0} # AUD + m = match_payouts([_row("Amazon Australia", "Australia", dt.date(2026, 1, 13), 94.42)], + [au], _MONTH_END, currency_by_marketplace=_CCY) # bank row is USD + assert len(m.matched) == 1 + got = m.matched[0] + assert got["amount_checked"] is False and got["delta"] is None + assert "USD" in got["note"] # FCY amount preserved in the note, not bank_amount + + +# --------------------------------------------------------------------------- endpoint +def _fresh(c, name: str) -> int: + sid = c.post("/api/sessions", json={ + "name": name, "reporting_month": "2026-01", + "month_end_date": "2026-01-31", "clearing_lag_days": 2, + "allow_duplicate": True, + }).json()["id"] + path = os.path.join(_TMP, f"USA {name}.xlsx") + make_amazon_xlsx(path, order_rows=4) + with open(path, "rb") as fh: + assert c.post(f"/api/sessions/{sid}/files", + files={"files": (os.path.basename(path), fh)}).status_code == 200 + assert c.post(f"/api/sessions/{sid}/process").status_code == 200 + assert c.get(f"/api/sessions/{sid}/status").json()["status"] == "processed" + return sid + + +def test_import_endpoint_end_to_end(): + init_db() + with TestClient(app) as c: + sid = _fresh(c, "bank import e2e") + bank = _mk("e2e.xlsx", [ + ("Amazon US", "USD", dt.datetime(2026, 1, 8), 1000.0), # -> sid 200 (Standard) + ("Amazon US", "USD", dt.datetime(2026, 1, 14), 300.0), # -> sid 250 (Invoiced) + ("Amazon US", "USD", dt.datetime(2026, 2, 3), 2000.0), # -> sid 300 (Standard) + ("Amazon US", "USD", dt.datetime(2026, 6, 20), 461.77), # other month + ]) + with open(bank, "rb") as fh: + r = c.post(f"/api/sessions/{sid}/payouts/receipts/import", + files={"file": ("bank.xlsx", fh)}) + assert r.status_code == 200, r.text + res = r.json() + assert res["total_rows"] == 4 and res["out_of_scope"] == 1 + assert {m["settlement_id"]: m["bank_date"] for m in res["matched"]} == { + "200": "2026-01-08", "250": "2026-01-14", "300": "2026-02-03"} + assert all(m["amount_checked"] and m["delta"] == 0.0 for m in res["matched"]) + + # Apply through the existing PUT — the endpoint itself must not have written. + payload = [{"marketplace": m["marketplace"], "account_type": m["account_type"], + "settlement_id": m["settlement_id"], "bank_date": m["bank_date"], + "bank_amount": m["bank_amount"] if m["amount_checked"] else None, + "note": m["note"]} for m in res["matched"]] + put = c.put(f"/api/sessions/{sid}/payouts/receipts", json=payload).json() + assert put["saved"] == 3 and put["needs_reprocess"] + + assert c.post(f"/api/sessions/{sid}/process").status_code == 200 + assert c.get(f"/api/sessions/{sid}/status").json()["status"] == "processed" + payouts = c.get(f"/api/sessions/{sid}/payouts").json()["payouts"] + by_sid = {p["settlement_id"]: p for p in payouts} + assert by_sid["200"]["received_now"] is True # bank Jan 8 <= month-end + assert by_sid["250"]["received_now"] is True + assert by_sid["300"]["received_now"] is False # bank Feb 3 > month-end + + # Re-import: matches flagged as already having receipts (idempotent workflow). + with open(bank, "rb") as fh: + res2 = c.post(f"/api/sessions/{sid}/payouts/receipts/import", + files={"file": ("bank.xlsx", fh)}).json() + assert all(m["already_had_receipt"] for m in res2["matched"]) + + +def test_import_rejects_wrong_file(): + init_db() + with TestClient(app) as c: + sid = _fresh(c, "bank import reject") + r = c.post(f"/api/sessions/{sid}/payouts/receipts/import", + files={"file": ("bank.csv", b"a,b,c")}) + assert r.status_code == 400 + wrong = os.path.join(_TMP, "not-bank.xlsx") + wb = openpyxl.Workbook() + wb.active.append(["Random", "Header"]) + wb.save(wrong) + with open(wrong, "rb") as fh: + r = c.post(f"/api/sessions/{sid}/payouts/receipts/import", + files={"file": ("not-bank.xlsx", fh)}) + assert r.status_code == 400 and "disbursements" in r.json()["detail"] diff --git a/ar-aging-app/frontend/src/components/BankReceipts.tsx b/ar-aging-app/frontend/src/components/BankReceipts.tsx index 2d7a91d..d9bc232 100644 --- a/ar-aging-app/frontend/src/components/BankReceipts.tsx +++ b/ar-aging-app/frontend/src/components/BankReceipts.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Banknote, CheckCircle2, Clock, RefreshCw, Save } from "lucide-react"; -import { api, PayoutT } from "../api/client"; +import { Banknote, CheckCircle2, Clock, RefreshCw, Save, Upload, X } from "lucide-react"; +import { api, PayoutImportT, PayoutT } from "../api/client"; import { acct, date as fmtDate } from "../lib/format"; import { InfoTip, Section, Spinner, useDefinitions } from "./ui"; @@ -56,6 +56,36 @@ export default function BankReceipts({ id, marketplace }: { id: number; marketpl onSuccess: () => qc.invalidateQueries({ queryKey: ["session", id] }), }); + // Bank-file import: upload -> server proposes matches -> user applies via the normal PUT. + const fileRef = useRef(null); + const [imported, setImported] = useState(null); + const [picked, setPicked] = useState>({}); // bank_row -> apply? + const importFile = useMutation({ + mutationFn: (file: File) => api.importPayoutReceipts(id, file), + onSuccess: (res) => { + setImported(res); + // Pre-select fresh matches; leave payouts that already have this receipt unticked. + setPicked(Object.fromEntries(res.matched.map((m) => [ + m.bank_row, !m.already_had_receipt || m.existing_bank_date !== m.bank_date, + ]))); + }, + }); + const applyImport = useMutation({ + mutationFn: () => { + const items = (imported?.matched ?? []) + .filter((m) => picked[m.bank_row]) + .map((m) => ({ + marketplace: m.marketplace, account_type: m.account_type, + settlement_id: m.settlement_id, bank_date: m.bank_date, + bank_amount: m.amount_checked ? m.bank_amount : null, + note: m.note, + })); + return api.putPayoutReceipts(id, items); + }, + onSuccess: () => { setImported(null); setPicked({}); invalidate(); }, + }); + const pickedCount = (imported?.matched ?? []).filter((m) => picked[m.bank_row]).length; + if (isLoading) return null; if (!data?.payouts?.length) return null; const manual = data.payout_mode === "manual"; @@ -75,6 +105,16 @@ export default function BankReceipts({ id, marketplace }: { id: number; marketpl onChange={(e) => setMode.mutate(e.target.checked ? "manual" : "auto")} /> Bank dates only (no clearing-lag) + { + const f = e.target.files?.[0]; + if (f) importFile.mutate(f); + e.target.value = ""; + }} /> + } > @@ -92,6 +132,84 @@ export default function BankReceipts({ id, marketplace }: { id: number; marketpl )} + {importFile.isError && ( +
+ Import failed: {(importFile.error as Error).message} +
+ )} + + {imported && ( +
+
+ + + {imported.matched.length} matched + {" · "}{imported.ambiguous.length} ambiguous + {" · "}{imported.unmatched_bank_rows.length} unmatched + {imported.unknown_party.length > 0 && <>{" · "}{imported.unknown_party.length} unknown party} + {" · "}{imported.out_of_scope} outside this month + ({imported.total_rows} deposit rows read) + + + +
+ {imported.matched.length > 0 && ( +
    + {imported.matched.map((m) => ( +
  • + setPicked((s) => ({ ...s, [m.bank_row]: e.target.checked }))} /> + {m.marketplace} · {m.settlement_id} + + bank {fmtDate(m.bank_date)} · {m.currency} {m.bank_amount.toLocaleString()} + {m.amount_checked + ? (m.delta ? ` · Δ ${m.delta}` : "") + : " · amount not compared (currency differs)"} + {m.already_had_receipt && ` · already had ${fmtDate(m.existing_bank_date)}`} + {" · file row "}{m.bank_row} + +
  • + ))} +
+ )} + {(imported.ambiguous.length > 0 || imported.unmatched_bank_rows.length > 0 + || imported.unknown_party.length > 0 || imported.problems.length > 0) && ( +
+ + Rows needing a manual look + +
    + {imported.ambiguous.map((r) => ( +
  • + row {r.bank_row} · {r.party} · {fmtDate(r.bank_date)} · {r.amount.toLocaleString()} — {r.reason} + {r.candidates?.length ? ` (candidates: ${r.candidates.map((c) => c.settlement_id).join(", ")})` : ""} +
  • + ))} + {imported.unmatched_bank_rows.map((r) => ( +
  • + row {r.bank_row} · {r.party} · {fmtDate(r.bank_date)} · {r.amount.toLocaleString()} — {r.reason} +
  • + ))} + {imported.unknown_party.map((r) => ( +
  • row {r.bank_row} · unrecognized party “{r.party}”
  • + ))} + {imported.problems.map((p, i) =>
  • {p}
  • )} +
+
+ )} + {applyImport.isError && ( +
+ {(applyImport.error as Error).message} +
+ )} +
+ )} +