Import bank disbursements Excel as payout receipts

POST /sessions/{id}/payouts/receipts/import parses the bank workbook (Payouts
sheet), matches deposits to the closing's Transfer payouts by marketplace +
date window + amount (currency-aware: converted deposits match by date only),
and returns a preview; the UI applies selected matches through the existing
PUT so upsert/reprocess semantics stay in one place. Replaces hand-typing
bank dates in the Bank receipts grid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main
Talha Ahmed 2026-08-24 22:09:49 +05:00
parent 089f775eeb
commit 04dfbcf0e6
4 changed files with 738 additions and 17 deletions

View File

@ -19,11 +19,14 @@ from __future__ import annotations
import datetime as dt 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 pydantic import BaseModel
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session as OrmSession 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 ...db import models
from ..auth import actor_name from ..auth import actor_name
from ..deps import db_dep, ensure_editable, get_session_or_404 from ..deps import db_dep, ensure_editable, get_session_or_404
@ -33,17 +36,9 @@ router = APIRouter(prefix="/api/sessions", tags=["payouts"])
TRANSFER = "Transfer" TRANSFER = "Transfer"
@router.get("/{session_id}/payouts") def _payout_rows(db: OrmSession, session_id: int, marketplace: str | None = None):
def list_payouts(session_id: int, marketplace: str | None = None, """One row per (marketplace, account stream, settlement id) — the key the engine
db: OrmSession = Depends(db_dep)) -> dict: classifies on: (mkt, acct, sid, max(posted_date), sum(total), count)."""
"""
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( q = db.query(
models.Transaction.marketplace, models.Transaction.marketplace,
models.Transaction.account_type, models.Transaction.account_type,
@ -57,9 +52,23 @@ def list_payouts(session_id: int, marketplace: str | None = None,
) )
if marketplace: if marketplace:
q = q.filter(models.Transaction.marketplace == marketplace) q = q.filter(models.Transaction.marketplace == marketplace)
q = q.group_by(models.Transaction.marketplace, models.Transaction.account_type, return q.group_by(models.Transaction.marketplace, models.Transaction.account_type,
models.Transaction.settlement_id) 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 receipts = {(r.marketplace, r.account_type, r.settlement_id): r
for r in db.query(models.PayoutReceipt).filter( for r in db.query(models.PayoutReceipt).filter(
models.PayoutReceipt.session_id == session_id)} models.PayoutReceipt.session_id == session_id)}
@ -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)} 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): class ModeIn(BaseModel):
mode: str mode: str

View File

@ -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

View File

@ -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"]

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Banknote, CheckCircle2, Clock, RefreshCw, Save } from "lucide-react"; import { Banknote, CheckCircle2, Clock, RefreshCw, Save, Upload, X } from "lucide-react";
import { api, PayoutT } from "../api/client"; import { api, PayoutImportT, PayoutT } from "../api/client";
import { acct, date as fmtDate } from "../lib/format"; import { acct, date as fmtDate } from "../lib/format";
import { InfoTip, Section, Spinner, useDefinitions } from "./ui"; 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] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["session", id] }),
}); });
// Bank-file import: upload -> server proposes matches -> user applies via the normal PUT.
const fileRef = useRef<HTMLInputElement>(null);
const [imported, setImported] = useState<PayoutImportT | null>(null);
const [picked, setPicked] = useState<Record<number, boolean>>({}); // 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 (isLoading) return null;
if (!data?.payouts?.length) return null; if (!data?.payouts?.length) return null;
const manual = data.payout_mode === "manual"; 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")} /> onChange={(e) => setMode.mutate(e.target.checked ? "manual" : "auto")} />
Bank dates only (no clearing-lag) Bank dates only (no clearing-lag)
</label> </label>
<input ref={fileRef} type="file" accept=".xlsx,.xls" className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) importFile.mutate(f);
e.target.value = "";
}} />
<button className="btn-ghost" disabled={importFile.isPending}
onClick={() => fileRef.current?.click()}>
{importFile.isPending ? <Spinner /> : <Upload size={14} />} Import from Excel
</button>
</div> </div>
} }
> >
@ -92,6 +132,84 @@ export default function BankReceipts({ id, marketplace }: { id: number; marketpl
</div> </div>
)} )}
{importFile.isError && (
<div className="mx-4 mt-3 rounded-lg border border-bad/30 bg-badbg/40 px-3 py-2 text-sm text-bad">
Import failed: {(importFile.error as Error).message}
</div>
)}
{imported && (
<div className="mx-4 mt-3 rounded-lg border border-line bg-canvas/40 text-sm">
<div className="px-3 py-2 flex flex-wrap items-center gap-3 border-b border-line">
<Upload size={15} className="text-primary shrink-0" />
<span className="flex-1 min-w-[240px]">
<b>{imported.matched.length} matched</b>
{" · "}{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
<span className="text-subink"> ({imported.total_rows} deposit rows read)</span>
</span>
<button className="btn-ghost" onClick={() => { setImported(null); setPicked({}); }}>
<X size={14} /> Dismiss
</button>
<button className="btn-primary" disabled={applyImport.isPending || pickedCount === 0}
onClick={() => applyImport.mutate()}>
<Save size={15} /> {applyImport.isPending ? "Applying…" : `Apply ${pickedCount} receipt(s)`}
</button>
</div>
{imported.matched.length > 0 && (
<ul className="px-3 py-2 space-y-1 max-h-56 overflow-y-auto">
{imported.matched.map((m) => (
<li key={m.bank_row} className="flex items-center gap-2">
<input type="checkbox" checked={!!picked[m.bank_row]}
onChange={(e) => setPicked((s) => ({ ...s, [m.bank_row]: e.target.checked }))} />
<span className="num text-xs">{m.marketplace} · {m.settlement_id}</span>
<span className="flex-1 text-xs text-subink">
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}
</span>
</li>
))}
</ul>
)}
{(imported.ambiguous.length > 0 || imported.unmatched_bank_rows.length > 0
|| imported.unknown_party.length > 0 || imported.problems.length > 0) && (
<details className="px-3 py-2 border-t border-line">
<summary className="cursor-pointer text-xs text-subink select-none">
Rows needing a manual look
</summary>
<ul className="mt-1.5 space-y-1 text-xs text-subink max-h-40 overflow-y-auto">
{imported.ambiguous.map((r) => (
<li key={`a${r.bank_row}`}>
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(", ")})` : ""}
</li>
))}
{imported.unmatched_bank_rows.map((r) => (
<li key={`u${r.bank_row}`}>
row {r.bank_row} · {r.party} · {fmtDate(r.bank_date)} · {r.amount.toLocaleString()} {r.reason}
</li>
))}
{imported.unknown_party.map((r) => (
<li key={`p${r.bank_row}`}>row {r.bank_row} · unrecognized party {r.party}</li>
))}
{imported.problems.map((p, i) => <li key={`q${i}`}>{p}</li>)}
</ul>
</details>
)}
{applyImport.isError && (
<div className="px-3 py-2 border-t border-line text-bad">
{(applyImport.error as Error).message}
</div>
)}
</div>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead><tr> <thead><tr>