Finance-Accounts/ar-aging-app/backend/tests/test_bank_import.py

228 lines
10 KiB
Python

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