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