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
parent
089f775eeb
commit
04dfbcf0e6
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue