Enhance API functionality and session management #1

Merged
sheheryar.soomro merged 2 commits from new-changes into main 2026-08-03 07:27:50 +00:00
55 changed files with 4797 additions and 238 deletions

View File

@ -23,6 +23,32 @@ def get_session_or_404(session_id: int, db: OrmSession) -> models.Session:
return s return s
def is_blocked(s: models.Session) -> bool:
"""A month-end control failed with error severity — no figure may be published."""
return bool(getattr(s, "blocked_reason", ""))
def blocked_payload(s: models.Session) -> dict:
"""Read-endpoint response for a blocked close: the reason, never a number."""
return {
"available": False,
"blocked": True,
"blocked_reason": s.blocked_reason or "",
"detail": ("A month-end control failed, so no receivable figure is published for this "
"closing. Resolve the failed control on the Controls tab and re-run it."),
}
def ensure_not_blocked(s: models.Session) -> None:
"""Guard for actions that would put an unverified number into someone's hands."""
if is_blocked(s):
raise HTTPException(
status_code=409,
detail=f"This closing is blocked by a failed month-end control — "
f"{s.blocked_reason}",
)
_SAFE = re.compile(r"[^A-Za-z0-9 ._,()\-]+") _SAFE = re.compile(r"[^A-Za-z0-9 ._,()\-]+")
@ -51,6 +77,10 @@ def session_dict(s: models.Session) -> dict:
"opening_mode", "opening_source_session_id", "opening_mode", "opening_source_session_id",
"error", "created_at", "updated_at", "error", "created_at", "updated_at",
]) ])
d["blocked"] = is_blocked(s)
d["blocked_reason"] = getattr(s, "blocked_reason", "") or ""
d["payout_mode"] = getattr(s, "payout_mode", "auto") or "auto"
d["needs_reprocess"] = bool(getattr(s, "needs_reprocess", False))
return d return d

View File

@ -11,7 +11,7 @@ from ..config import CORS_ORIGINS
from ..db.database import init_db from ..db.database import init_db
from .routes import ( from .routes import (
sessions, files, processing, results, settings as settings_routes, export, ar, control, sessions, files, processing, results, settings as settings_routes, export, ar, control,
analytics, analytics, controls, payouts, accounts_summary,
) )
@ -43,7 +43,11 @@ app.include_router(processing.router)
app.include_router(results.router) app.include_router(results.router)
app.include_router(settings_routes.router) app.include_router(settings_routes.router)
app.include_router(settings_routes.rules_router) app.include_router(settings_routes.rules_router)
app.include_router(settings_routes.meta_router)
app.include_router(export.router) app.include_router(export.router)
app.include_router(ar.router) app.include_router(ar.router)
app.include_router(control.router) app.include_router(control.router)
app.include_router(analytics.router) app.include_router(analytics.router)
app.include_router(controls.router)
app.include_router(payouts.router)
app.include_router(accounts_summary.router)

View File

@ -0,0 +1,87 @@
"""
Accounts Summary the cross-month view of APPROVED journal entries.
One row per (month, marketplace): the journal's GL lines (accrual presentation — Transfer
excluded, Receivable = net revenue Dr A/R) in local currency plus the session's FX rate, so
the UI can show any marketplace across every month, or all marketplaces converted to USD.
Only months whose journal has been APPROVED appear approval on the Journal Entry tab is
the publish step. A month drops out again if its sign-off is withdrawn, if it is
re-processed (the sign-off clears with the numbers), or if a month-end control blocks it.
"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session as OrmSession
from ...core.journal import ACCRUAL_DISPLAY_EXCLUDES, LINE_ACCOUNTS, RECEIVABLE_KEY
from ...db import models
from ..deps import db_dep, is_blocked
router = APIRouter(prefix="/api", tags=["accounts-summary"])
@router.get("/accounts-summary")
def accounts_summary(db: OrmSession = Depends(db_dep)) -> dict:
from .ar import _journal_for, _market_list, fx_for
entries = (
db.query(models.JournalEntry, models.Session)
.join(models.Session, models.Session.id == models.JournalEntry.session_id)
.filter(models.JournalEntry.approved_by != "")
.order_by(models.Session.reporting_month, models.Session.id)
.all()
)
line_keys = [k for k, _ in LINE_ACCOUNTS if k not in ACCRUAL_DISPLAY_EXCLUDES]
months: list[dict] = []
cells: list[dict] = []
marketplaces: set[str] = set()
for j, s in entries:
if is_blocked(s) or not j.data:
continue # a blocked closing publishes nothing
payload = json.loads(j.data)
month = s.reporting_month or (s.month_end_date.isoformat()[:7]
if s.month_end_date else f"session-{s.id}")
months.append({
"month": month,
"session_id": s.id,
"session_name": s.name,
"reviewed_by": j.reviewed_by or "",
"approved_by": j.approved_by or "",
"approved_at": j.approved_at.isoformat() if j.approved_at else None,
"entry_no": j.entry_no or "",
})
for mkt in _market_list(payload):
sub, _ = _journal_for(payload, mkt)
rate, currency = fx_for(db, s.id, mkt)
values = {ln["key"]: ln["total"] for ln in sub.get("lines", [])
if ln["key"] not in ACCRUAL_DISPLAY_EXCLUDES}
accrual = sub.get("receivable_accrual")
if accrual is None:
# Payload stored before the accrual figure existed: derive it.
accrual_total = -round(sum(values.values()), 2)
else:
accrual_total = accrual["total"]
marketplaces.add(mkt)
cells.append({
"month": month,
"session_id": s.id,
"marketplace": mkt,
"currency": currency,
"fx_rate": rate,
"values": values, # line key -> local-currency total
"receivable": accrual_total, # Dr A/R (net revenue accrued)
})
return {
"available": bool(months),
"line_keys": line_keys,
"receivable_key": RECEIVABLE_KEY,
"months": months,
"marketplaces": sorted(marketplaces),
"cells": cells,
}

View File

@ -18,12 +18,13 @@ from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session as OrmSession from sqlalchemy.orm import Session as OrmSession
from ...core.i18n import currency_for_region, default_fx_for_region from ...core.i18n import currency_for_region, default_fx_for_region
from ...db import models from ...db import models
from ..deps import db_dep, get_session_or_404 from ..deps import db_dep, get_session_or_404
from .ar import _market_list, _movement_for, _payouts_for from .ar import _market_list, _movement_for, _payouts_for, fx_for
router = APIRouter(prefix="/api/sessions", tags=["analytics"]) router = APIRouter(prefix="/api/sessions", tags=["analytics"])
@ -80,15 +81,17 @@ def _fx_for(db: OrmSession, session_id: int, marketplace: str) -> tuple[float, d
def _daily_rows(db: OrmSession, session_id: int, marketplace: str, def _daily_rows(db: OrmSession, session_id: int, marketplace: str,
frm: dt.date | None, to: dt.date | None) -> list[tuple[dt.date, float, float, int]]: frm: dt.date | None, to: dt.date | None) -> list[tuple[dt.date, float, int]]:
"""Per-day (date, revenue_total, payout_total, row_count) for one marketplace.""" """Per-day (date, revenue_total, row_count) for one marketplace — NON-transfer rows.
Payouts are handled separately (see _payout_events) so bank-receipt dates can re-date
them."""
q = db.query( q = db.query(
models.Transaction.posted_date, models.Transaction.posted_date,
models.Transaction.txn_type_en,
models.Transaction.total, models.Transaction.total,
).filter( ).filter(
models.Transaction.session_id == session_id, models.Transaction.session_id == session_id,
models.Transaction.marketplace == marketplace, models.Transaction.marketplace == marketplace,
models.Transaction.txn_type_en != TRANSFER,
) )
if frm: if frm:
q = q.filter(models.Transaction.posted_date >= frm) q = q.filter(models.Transaction.posted_date >= frm)
@ -97,22 +100,65 @@ def _daily_rows(db: OrmSession, session_id: int, marketplace: str,
if frm or to: # an explicit range excludes undated rows if frm or to: # an explicit range excludes undated rows
q = q.filter(models.Transaction.posted_date.isnot(None)) q = q.filter(models.Transaction.posted_date.isnot(None))
per: dict[dt.date | None, list[float]] = defaultdict(lambda: [0.0, 0.0, 0]) per: dict[dt.date | None, list[float]] = defaultdict(lambda: [0.0, 0])
for posted, type_en, total in q: for posted, total in q:
if posted is None: if posted is None:
d = None d = None
else: else:
d = posted if isinstance(posted, dt.date) else dt.date.fromisoformat(str(posted)) d = posted if isinstance(posted, dt.date) else dt.date.fromisoformat(str(posted))
slot = per[d] slot = per[d]
if type_en == TRANSFER:
slot[1] += total or 0.0
else:
slot[0] += total or 0.0 slot[0] += total or 0.0
slot[2] += 1 slot[1] += 1
return [(d, v[0], v[1], int(v[2])) return [(d, v[0], int(v[1]))
for d, v in sorted(per.items(), key=lambda kv: (kv[0] is not None, kv[0]))] for d, v in sorted(per.items(), key=lambda kv: (kv[0] is not None, kv[0]))]
def _payout_events(db: OrmSession, s: models.Session, marketplace: str,
) -> list[tuple[dt.date | None, float, bool, bool]]:
"""
Each payout as (effective_date, amount, received, bank_dated).
With a bank receipt the payout is dated on the day the money reached the BANK and
received iff that day is month-end Amazon's transfer date only says when the payout
was initiated. Without a receipt: manual mode in transit; auto mode the clearing-lag
heuristic on Amazon's date. Mirrors the engine's classify() rules exactly.
"""
rows = db.query(
models.Transaction.account_type,
models.Transaction.settlement_id,
models.Transaction.posted_date,
func.sum(models.Transaction.total),
).filter(
models.Transaction.session_id == s.id,
models.Transaction.marketplace == marketplace,
models.Transaction.txn_type_en == TRANSFER,
).group_by(models.Transaction.account_type, models.Transaction.settlement_id,
models.Transaction.posted_date).all()
receipts = {(r.account_type, r.settlement_id): r
for r in db.query(models.PayoutReceipt).filter(
models.PayoutReceipt.session_id == s.id,
models.PayoutReceipt.marketplace == marketplace)}
month_end = s.month_end_date
cutoff = _session_cutoff(s)
manual = (s.payout_mode or "auto") == "manual"
out: list[tuple[dt.date | None, float, bool, bool]] = []
for acct, sid, posted, amount in rows:
d = posted if (posted is None or isinstance(posted, dt.date)) else \
dt.date.fromisoformat(str(posted))
rec = receipts.get((acct, sid))
if rec is not None and rec.bank_date:
out.append((rec.bank_date, amount or 0.0,
bool(month_end and rec.bank_date <= month_end), True))
elif manual:
out.append((d, amount or 0.0, False, False))
else:
out.append((d, amount or 0.0,
bool(d and cutoff and d <= cutoff), False))
return out
# --------------------------------------------------------------------------- ledger # --------------------------------------------------------------------------- ledger
@router.get("/{session_id}/ledger-detail") @router.get("/{session_id}/ledger-detail")
def ledger_detail(session_id: int, marketplace: str | None = None, granularity: str = "day", def ledger_detail(session_id: int, marketplace: str | None = None, granularity: str = "day",
@ -127,24 +173,35 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity:
return {"available": False} return {"available": False}
mv.pop("journal", None) mv.pop("journal", None)
mkt = mv["marketplace"] mkt = mv["marketplace"]
cutoff = _session_cutoff(s)
frm, to = _parse_date(date_from, "date_from"), _parse_date(date_to, "date_to") frm, to = _parse_date(date_from, "date_from"), _parse_date(date_to, "date_to")
rows = _daily_rows(db, session_id, mkt, frm, to) rows = _daily_rows(db, session_id, mkt, frm, to)
# Bucket, splitting payouts into received (hit the balance) and in-transit (memo only). def new_bucket(key: str, label: str) -> dict:
return {"key": key, "label": label, "revenue": 0.0,
"payouts_received": 0.0, "payouts_in_transit": 0.0,
"bank_dated": 0.0, "rows": 0}
# Revenue buckets by transaction date; payouts by their EFFECTIVE date — the bank
# receipt's date when Finance entered one, Amazon's transfer date otherwise.
buckets: dict[str, dict] = {} buckets: dict[str, dict] = {}
for d, revenue, payout, n in rows: for d, revenue, n in rows:
key, label = _bucket(d, granularity) key, label = _bucket(d, granularity)
b = buckets.setdefault(key, { b = buckets.setdefault(key, new_bucket(key, label))
"key": key, "label": label, "revenue": 0.0,
"payouts_received": 0.0, "payouts_in_transit": 0.0, "rows": 0,
})
b["revenue"] += revenue b["revenue"] += revenue
if payout:
received = cutoff is not None and d is not None and d <= cutoff
b["payouts_received" if received else "payouts_in_transit"] += payout
b["rows"] += n b["rows"] += n
for d, amount, received, bank_dated in _payout_events(db, s, mkt):
if frm and (d is None or d < frm):
continue
if to and (d is None or d > to):
continue
key, label = _bucket(d, granularity)
b = buckets.setdefault(key, new_bucket(key, label))
if amount:
b["payouts_received" if received else "payouts_in_transit"] += amount
if bank_dated:
b["bank_dated"] += amount
b["rows"] += 1
opening = mv["opening"] opening = mv["opening"]
running = opening running = opening
@ -157,6 +214,7 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity:
"revenue": round(b["revenue"], 2), "revenue": round(b["revenue"], 2),
"payouts_received": round(b["payouts_received"], 2), "payouts_received": round(b["payouts_received"], 2),
"payouts_in_transit": round(b["payouts_in_transit"], 2), "payouts_in_transit": round(b["payouts_in_transit"], 2),
"bank_dated": round(b["bank_dated"], 2), # payout amounts placed by bank date
"rows": b["rows"], "rows": b["rows"],
"balance": round(running, 2), "balance": round(running, 2),
}) })
@ -367,8 +425,6 @@ def all_markets(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
return {"available": False} return {"available": False}
markets = _market_list(json.loads(j.data)) markets = _market_list(json.loads(j.data))
fx = {r.marketplace: (r.rate, r.currency)
for r in db.query(models.FxRate).filter(models.FxRate.session_id == session_id)}
settle = {r.marketplace: r for r in db.query(models.ReceivableResultRow).filter( settle = {r.marketplace: r for r in db.query(models.ReceivableResultRow).filter(
models.ReceivableResultRow.session_id == session_id, models.ReceivableResultRow.session_id == session_id,
models.ReceivableResultRow.account_type == "TOTAL")} models.ReceivableResultRow.account_type == "TOTAL")}
@ -380,7 +436,8 @@ def all_markets(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
if not mv.get("available"): if not mv.get("available"):
continue continue
mv.pop("journal", None) mv.pop("journal", None)
rate, currency = fx.get(mkt, (default_fx_for_region(mkt), currency_for_region(mkt))) # Shared with the Reconciliation Control so the two surfaces cannot drift apart.
rate, currency = fx_for(db, session_id, mkt)
st = settle.get(mkt) st = settle.get(mkt)
closing_local = mv["closing"] closing_local = mv["closing"]
row = { row = {

View File

@ -7,9 +7,10 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session as OrmSession from sqlalchemy.orm import Session as OrmSession
from ...core.i18n import currency_for_region, default_fx_for_region
from ...core.movement import compute_movement from ...core.movement import compute_movement
from ...db import models from ...db import models
from ..deps import db_dep, get_session_or_404, to_dict from ..deps import blocked_payload, db_dep, get_session_or_404, is_blocked, to_dict
router = APIRouter(prefix="/api/sessions", tags=["ar"]) router = APIRouter(prefix="/api/sessions", tags=["ar"])
@ -47,7 +48,8 @@ def get_openings(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict
@router.put("/{session_id}/opening-balances") @router.put("/{session_id}/opening-balances")
def put_openings(session_id: int, items: list[OpeningIn], def put_openings(session_id: int, items: list[OpeningIn],
db: OrmSession = Depends(db_dep)) -> list[dict]: db: OrmSession = Depends(db_dep)) -> list[dict]:
get_session_or_404(session_id, db) """Set one or more marketplaces' opening balances (only the ones sent are touched)."""
s = get_session_or_404(session_id, db)
existing = {o.marketplace: o for o in db.query(models.OpeningBalance).filter( existing = {o.marketplace: o for o in db.query(models.OpeningBalance).filter(
models.OpeningBalance.session_id == session_id)} models.OpeningBalance.session_id == session_id)}
for it in items: for it in items:
@ -58,10 +60,20 @@ def put_openings(session_id: int, items: list[OpeningIn],
o.amount = it.amount o.amount = it.amount
o.reason = it.reason o.reason = it.reason
o.source = it.source or "manual" o.source = it.source or "manual"
if items:
s.opening_mode = "manual"
db.commit() db.commit()
_revalidate(db, session_id, s)
return get_openings(session_id, db) return get_openings(session_id, db)
def _revalidate(db: OrmSession, session_id: int, s: models.Session) -> None:
"""Opening balances feed the roll-forward, so control C4 has to be re-evaluated."""
if s.status in ("processed", "blocked", "completed"):
from ...services.controls_run import run_and_persist
run_and_persist(db, session_id)
def _market_list(journal: dict) -> list[str]: def _market_list(journal: dict) -> list[str]:
"""Marketplaces available in a stored journal payload (primary first, then alphabetical).""" """Marketplaces available in a stored journal payload (primary first, then alphabetical)."""
primary = journal.get("marketplace") primary = journal.get("marketplace")
@ -84,6 +96,21 @@ def _journal_for(journal: dict, marketplace: str | None) -> tuple[dict, str]:
return sub, marketplace return sub, marketplace
def fx_for(db: OrmSession, session_id: int, marketplace: str) -> tuple[float, str]:
"""
(rate_to_usd, currency) for a marketplace the single shared resolution used by every
surface that converts. Never falls back to a bare "USD": an unknown marketplace resolves
its currency from the marketplace config, so EUR amounts can't render labelled USD.
"""
row = db.query(models.FxRate).filter(
models.FxRate.session_id == session_id,
models.FxRate.marketplace == marketplace).first()
if row is not None:
return (row.rate if row.rate is not None else default_fx_for_region(marketplace),
row.currency or currency_for_region(marketplace))
return default_fx_for_region(marketplace), currency_for_region(marketplace)
def _payouts_for(db: OrmSession, session_id: int, marketplace: str, def _payouts_for(db: OrmSession, session_id: int, marketplace: str,
markets: list[str]) -> tuple[float, float]: markets: list[str]) -> tuple[float, float]:
"""Per-marketplace payouts; falls back to session totals for pre-upgrade single-market runs.""" """Per-marketplace payouts; falls back to session totals for pre-upgrade single-market runs."""
@ -121,17 +148,21 @@ def _movement_for(db: OrmSession, session_id: int, marketplace: str | None) -> d
models.ReceivableResultRow.account_type == "TOTAL").first() models.ReceivableResultRow.account_type == "TOTAL").first()
received, all_p = _payouts_for(db, session_id, mkt, markets) received, all_p = _payouts_for(db, session_id, mkt, markets)
rate, currency = fx_for(db, session_id, mkt)
mv = compute_movement( mv = compute_movement(
journal, journal,
received_payouts=received, received_payouts=received,
all_payouts=all_p, all_payouts=all_p,
opening=opening_row.amount if opening_row else 0.0, opening=opening_row.amount if opening_row else 0.0,
settlement_closing=round(settlement.receivable_local) if settlement else None, settlement_closing=round(settlement.receivable_local) if settlement else None,
currency=(settlement.currency if settlement else "USD"), currency=(settlement.currency if settlement else currency),
) )
mv["available"] = True mv["available"] = True
mv["marketplace"] = mkt mv["marketplace"] = mkt
mv["marketplaces"] = markets mv["marketplaces"] = markets
# Every figure in `mv` is LOCAL currency. Callers that roll several marketplaces together
# must convert with this rate — never add the locals (see core/money.Total).
mv["fx_rate"] = rate
mv["journal"] = journal mv["journal"] = journal
mv["opening_source"] = opening_row.source if opening_row else "manual" mv["opening_source"] = opening_row.source if opening_row else "manual"
mv["opening_reason"] = opening_row.reason if opening_row else "" mv["opening_reason"] = opening_row.reason if opening_row else ""
@ -201,7 +232,9 @@ def build_finance_summary(db: OrmSession, session_id: int,
@router.get("/{session_id}/finance-summary") @router.get("/{session_id}/finance-summary")
def finance_summary(session_id: int, marketplace: str | None = None, def finance_summary(session_id: int, marketplace: str | None = None,
db: OrmSession = Depends(db_dep)) -> dict: db: OrmSession = Depends(db_dep)) -> dict:
get_session_or_404(session_id, db) s = get_session_or_404(session_id, db)
if is_blocked(s):
return blocked_payload(s)
return build_finance_summary(db, session_id, marketplace) return build_finance_summary(db, session_id, marketplace)
@ -250,6 +283,79 @@ def opening_candidates(session_id: int, db: OrmSession = Depends(db_dep)) -> dic
"candidates": out} "candidates": out}
@router.get("/{session_id}/opening-balances/worksheet")
def opening_worksheet(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
"""
Every marketplace's opening balance in one place, with the effect of changing it.
The closing receivable is computed two independent ways and they must agree:
roll-forward = opening + net revenue - payouts received
settlement = ROUND(reserve + additional sales)
The opening is the only unknown in the roll-forward, so this returns, per marketplace,
the current opening, the resulting variance against the settlement method, and the
`implied` opening that would close that variance exactly.
`implied` is a diagnostic, not an answer: adopting it forces agreement and would make
control C4 pass by construction. The correct opening is the prior month's closing
balance carried forward from a processed closing, or typed from the prior workbook.
"""
s = get_session_or_404(session_id, db)
payload = _journal_payload(db, session_id)
markets = _market_list(payload)
if not markets:
return {"available": False}
openings = {o.marketplace: o for o in db.query(models.OpeningBalance).filter(
models.OpeningBalance.session_id == session_id)}
rows, total_variance = [], 0.0
for mkt in markets:
mv = _movement_for(db, session_id, mkt)
if not mv.get("available"):
continue
mv.pop("journal", None)
o = openings.get(mkt)
settlement = mv.get("settlement_closing")
# closing = opening + net_revenue + received_payouts (payouts are stored negative)
movement = round(mv["net_revenue"] + mv["received_payouts"], 2)
implied = round(settlement - movement, 2) if settlement is not None else None
variance = mv.get("difference_vs_settlement")
rate, currency = fx_for(db, session_id, mkt)
rows.append({
"marketplace": mkt,
"currency": currency,
"fx_rate": rate,
"opening": round(o.amount, 2) if o else 0.0,
"source": (o.source if o else "zero") or "zero",
"reason": (o.reason if o else "") or "",
"net_revenue": mv["net_revenue"],
"payouts_received": mv["received_payouts"],
"movement": movement,
"roll_forward_closing": mv["closing"],
"settlement_closing": settlement,
"variance": variance,
"implied_opening": implied,
"reconciled": variance is not None and abs(variance) < 1.0,
})
if variance:
total_variance += abs(variance) * (rate or 1.0)
cands = opening_candidates(session_id, db)
return {
"available": True,
"reporting_month": s.reporting_month or "",
"mode": s.opening_mode or "zero",
"source_session_id": s.opening_source_session_id,
"rows": rows,
"all_zero": all(r["opening"] == 0.0 for r in rows),
"unreconciled": sum(1 for r in rows if not r["reconciled"]),
"total_abs_variance_usd": round(total_variance, 2),
"candidates": cands["candidates"],
}
class CarryForwardIn(BaseModel): class CarryForwardIn(BaseModel):
from_session_id: int | None = None # defaults to the most recent prior closing from_session_id: int | None = None # defaults to the most recent prior closing
@ -287,6 +393,7 @@ def carry_forward(session_id: int, body: CarryForwardIn | None = None,
s.opening_mode = "carry_forward" s.opening_mode = "carry_forward"
s.opening_source_session_id = src_id s.opening_source_session_id = src_id
db.commit() db.commit()
_revalidate(db, session_id, s)
return {"applied": len(closings), "from_session_id": src_id, "from": prior.name, return {"applied": len(closings), "from_session_id": src_id, "from": prior.name,
"balances": get_openings(session_id, db)} "balances": get_openings(session_id, db)}
@ -303,6 +410,7 @@ def reset_openings(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
s.opening_mode = "zero" s.opening_mode = "zero"
s.opening_source_session_id = None s.opening_source_session_id = None
db.commit() db.commit()
_revalidate(db, session_id, s)
return {"balances": get_openings(session_id, db)} return {"balances": get_openings(session_id, db)}

View File

@ -8,9 +8,9 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session as OrmSession from sqlalchemy.orm import Session as OrmSession
from ...core.movement import compute_movement from ...core.money import USD, Total, to_usd
from ...db import models from ...db import models
from ..deps import db_dep, get_session_or_404 from ..deps import db_dep, ensure_not_blocked, get_session_or_404
router = APIRouter(prefix="/api/sessions", tags=["control"]) router = APIRouter(prefix="/api/sessions", tags=["control"])
@ -24,29 +24,44 @@ METRICS = [
] ]
def _dashboard_metrics(db: OrmSession, session_id: int) -> dict[str, float] | None: METRIC_KEYS = ("gross_sales", "refunds", "net_revenue", "disbursements", "closing_receivable")
"""Whole-close figures: summed across every marketplace in the session.
(Identical to the single-market numbers for a USA-only close.)
def _dashboard_metrics(db: OrmSession, session_id: int) -> dict[str, float] | None:
""" """
from .ar import _market_list, _movement_for Whole-close figures in **USD**, summed across every marketplace in the session.
Each marketplace's movement is stated in its own local currency, so every figure is
converted at that marketplace's session FX rate before being added. Summing the locals
(which this used to do) understated the Jan-2026 close by USD 444,658.44 and that was
the figure gating sign-off. `Total.add_converted` makes the raw sum impossible.
"""
from .ar import _market_list, _movement_for, fx_for
j = db.query(models.JournalEntry).filter(models.JournalEntry.session_id == session_id).first() j = db.query(models.JournalEntry).filter(models.JournalEntry.session_id == session_id).first()
if not j or not j.data: if not j or not j.data:
return None return None
payload = json.loads(j.data) payload = json.loads(j.data)
totals = {k: 0.0 for k in ("gross_sales", "refunds", "net_revenue", totals = {k: Total(USD) for k in METRIC_KEYS}
"disbursements", "closing_receivable")}
for mkt in _market_list(payload): for mkt in _market_list(payload):
mv = _movement_for(db, session_id, mkt) mv = _movement_for(db, session_id, mkt)
if not mv.get("available"): if not mv.get("available"):
continue continue
rate, _currency = fx_for(db, session_id, mkt)
lines = mv.pop("journal").get("lines", []) lines = mv.pop("journal").get("lines", [])
totals["gross_sales"] += next((l["total"] for l in lines if l["key"] == "Sales"), 0.0) local = {
totals["refunds"] += next((l["total"] for l in lines if l["key"] == "Refunds"), 0.0) "gross_sales": next((l["total"] for l in lines if l["key"] == "Sales"), 0.0),
totals["net_revenue"] += mv["net_revenue"] "refunds": next((l["total"] for l in lines if l["key"] == "Refunds"), 0.0),
totals["disbursements"] += mv["received_payouts"] "net_revenue": mv["net_revenue"],
totals["closing_receivable"] += mv["closing"] "disbursements": mv["received_payouts"],
return {k: round(v, 2) for k, v in totals.items()} "closing_receivable": mv["closing"],
}
for k, v in local.items():
# Round per marketplace, exactly as the All-Markets roll-up does, so the two
# surfaces agree to the cent by construction. Accumulating unrounded here left a
# 1-cent gap on the 13-market Jan-2026 close — right on control C6's tolerance,
# which would eventually fire as a false alarm on pure floating-point noise.
totals[k].add(to_usd(v, rate), USD)
return {k: t.value for k, t in totals.items()}
class ControlIn(BaseModel): class ControlIn(BaseModel):
@ -87,6 +102,7 @@ def get_control(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
return { return {
"available": True, "available": True,
"tolerance": tol, "tolerance": tol,
"currency": USD, # every figure here is USD-converted, never a mix of locals
"rows": rows, "rows": rows,
"verified_by": fc.verified_by if fc else "", "verified_by": fc.verified_by if fc else "",
"verified_at": fc.verified_at.isoformat() if fc and fc.verified_at else None, "verified_at": fc.verified_at.isoformat() if fc and fc.verified_at else None,
@ -110,8 +126,18 @@ def put_control(session_id: int, body: ControlIn, db: OrmSession = Depends(db_de
get_session_or_404(session_id, db) get_session_or_404(session_id, db)
fc = _get_or_create(db, session_id) fc = _get_or_create(db, session_id)
data = body.model_dump(exclude_unset=True) data = body.model_dump(exclude_unset=True)
# A sign-off attests to specific numbers. If any control figure or the tolerance changes,
# the previous attestation no longer applies and must be re-given — otherwise a closing can
# read "verified by X" against figures X never saw.
invalidates = {k for k in data if k in METRIC_KEYS or k == "tolerance"}
changed = {k for k in invalidates if getattr(fc, k, None) != data[k]}
for k, v in data.items(): for k, v in data.items():
setattr(fc, k, v) setattr(fc, k, v)
if changed and (fc.verified_by or fc.verified_at):
fc.verified_by = ""
fc.verified_at = None
fc.comment = (f"Sign-off cleared automatically: {', '.join(sorted(changed))} "
f"changed after verification.")
db.commit() db.commit()
return get_control(session_id, db) return get_control(session_id, db)
@ -136,6 +162,7 @@ def verify_control(session_id: int, body: VerifyIn, db: OrmSession = Depends(db_
@router.post("/{session_id}/complete") @router.post("/{session_id}/complete")
def complete_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: def complete_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
s = get_session_or_404(session_id, db) s = get_session_or_404(session_id, db)
ensure_not_blocked(s)
ctrl = get_control(session_id, db) ctrl = get_control(session_id, db)
if not ctrl.get("available"): if not ctrl.get("available"):
raise HTTPException(400, "Process the closing before completing it.") raise HTTPException(400, "Process the closing before completing it.")

View File

@ -0,0 +1,95 @@
"""Month-end controls: view, re-run, and confirm the FX rates control C5 requires."""
from __future__ import annotations
import datetime as dt
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session as OrmSession
from ...db import models
from ...services.controls_run import payload, run_and_persist
from ..deps import db_dep, get_session_or_404
router = APIRouter(prefix="/api/sessions", tags=["controls"])
@router.get("/{session_id}/controls")
def get_controls(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
get_session_or_404(session_id, db)
return payload(db, session_id)
@router.post("/{session_id}/controls/run")
def rerun_controls(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
"""
Re-evaluate the controls without re-parsing the source files.
Changing an opening balance, an FX rate or a reserve changes what the controls should
say, so this is what clears (or re-applies) a block after a fix.
"""
s = get_session_or_404(session_id, db)
if s.status == "processing":
raise HTTPException(409, "This closing is still processing — wait for it to finish.")
return run_and_persist(db, session_id)
class FxConfirmIn(BaseModel):
marketplace: str
rate: float | None = None # optionally correct the rate while confirming it
currency: str | None = None
confirmed_by: str
@router.post("/{session_id}/fx/confirm")
def confirm_fx(session_id: int, body: FxConfirmIn, db: OrmSession = Depends(db_dep)) -> dict:
"""
Record that a human confirmed this marketplace's rate FOR THIS REPORTING MONTH.
Control C5 treats a seeded default as missing, because the seeded table is a Jan-2026
snapshot and would otherwise value any later month at January's rates in silence.
"""
s = get_session_or_404(session_id, db)
if not body.confirmed_by.strip():
raise HTTPException(400, "confirmed_by is required — a rate is confirmed by a person.")
row = db.query(models.FxRate).filter(
models.FxRate.session_id == session_id,
models.FxRate.marketplace == body.marketplace).first()
if row is None:
row = models.FxRate(session_id=session_id, marketplace=body.marketplace)
db.add(row)
if body.rate is not None:
row.rate = body.rate
if body.currency:
row.currency = body.currency
row.confirmed_by = body.confirmed_by.strip()
row.confirmed_at = dt.datetime.utcnow()
row.confirmed_month = s.reporting_month or ""
row.source = f"confirmed by {row.confirmed_by}"
db.commit()
return run_and_persist(db, session_id)
class FxConfirmAllIn(BaseModel):
confirmed_by: str
@router.post("/{session_id}/fx/confirm-all")
def confirm_all_fx(session_id: int, body: FxConfirmAllIn,
db: OrmSession = Depends(db_dep)) -> dict:
"""Confirm every rate on the closing as-is (after reviewing them on the Settings tab)."""
s = get_session_or_404(session_id, db)
who = body.confirmed_by.strip()
if not who:
raise HTTPException(400, "confirmed_by is required — a rate is confirmed by a person.")
now = dt.datetime.utcnow()
n = 0
for row in db.query(models.FxRate).filter(models.FxRate.session_id == session_id):
row.confirmed_by = who
row.confirmed_at = now
row.confirmed_month = s.reporting_month or ""
n += 1
db.commit()
out = run_and_persist(db, session_id)
out["confirmed"] = n
return out

View File

@ -9,7 +9,7 @@ from sqlalchemy.orm import Session as OrmSession
from ...db import models from ...db import models
from ...services.jobs import run_export, run_summary_export from ...services.jobs import run_export, run_summary_export
from ..deps import db_dep, get_session_or_404 from ..deps import db_dep, ensure_not_blocked, get_session_or_404
router = APIRouter(prefix="/api/sessions", tags=["export"]) router = APIRouter(prefix="/api/sessions", tags=["export"])
@ -21,6 +21,8 @@ def start_export(session_id: int, background: BackgroundTasks, kind: str = "full
if kind not in ("full", "summary"): if kind not in ("full", "summary"):
raise HTTPException(400, "kind must be 'full' or 'summary'.") raise HTTPException(400, "kind must be 'full' or 'summary'.")
s = get_session_or_404(session_id, db) s = get_session_or_404(session_id, db)
# An export is the number leaving the building — never generate one from a blocked close.
ensure_not_blocked(s)
if s.status not in ("processed", "exporting", "completed"): if s.status not in ("processed", "exporting", "completed"):
raise HTTPException(400, "Process the session before exporting.") raise HTTPException(400, "Process the session before exporting.")
if s.status == "exporting": if s.status == "exporting":
@ -51,7 +53,9 @@ def list_exports(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict
@router.get("/{session_id}/export/download") @router.get("/{session_id}/export/download")
def download_export(session_id: int, kind: str = "full", db: OrmSession = Depends(db_dep)): def download_export(session_id: int, kind: str = "full", db: OrmSession = Depends(db_dep)):
get_session_or_404(session_id, db) s = get_session_or_404(session_id, db)
# A workbook generated before a control started failing must not keep circulating.
ensure_not_blocked(s)
q = db.query(models.ExportRecord).filter(models.ExportRecord.session_id == session_id) q = db.query(models.ExportRecord).filter(models.ExportRecord.session_id == session_id)
if kind in ("full", "summary"): if kind in ("full", "summary"):
q = q.filter(models.ExportRecord.kind == kind) q = q.filter(models.ExportRecord.kind == kind)

View File

@ -0,0 +1,177 @@
"""
Bank receipts for Amazon payouts.
Amazon's Transfer row is dated when the payout was INITIATED; the money reaches the bank
3-5 working days later. Finance records the actual bank date (and amount) per payout here,
and that record not the transfer date decides received vs in-transit:
received bank_date month-end
payout_mode:
auto (default) a payout without a receipt falls back to the clearing-lag heuristic
manual a payout without a receipt is NOT received no heuristic at all
Changing receipts or the mode only takes effect when the closing is re-processed (the
classification is computed during processing); until then the session carries
`needs_reprocess` and the UI shows a banner.
"""
from __future__ import annotations
import datetime as dt
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session as OrmSession
from ...db import models
from ..deps import db_dep, get_session_or_404
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)
q = db.query(
models.Transaction.marketplace,
models.Transaction.account_type,
models.Transaction.settlement_id,
func.max(models.Transaction.posted_date),
func.sum(models.Transaction.total),
func.count(),
).filter(
models.Transaction.session_id == session_id,
models.Transaction.txn_type_en == TRANSFER,
)
if marketplace:
q = q.filter(models.Transaction.marketplace == marketplace)
q = q.group_by(models.Transaction.marketplace, models.Transaction.account_type,
models.Transaction.settlement_id)
receipts = {(r.marketplace, r.account_type, r.settlement_id): r
for r in db.query(models.PayoutReceipt).filter(
models.PayoutReceipt.session_id == session_id)}
# Classification as of the last processing run (what the ledger currently uses).
status = {(r.marketplace, r.account_type, r.settlement_id): r.transfer_received
for r in db.query(models.Settlement).filter(
models.Settlement.session_id == session_id)
if r.transfer_received is not None}
month_end = s.month_end_date
out = []
for mkt, acct, sid, amazon_date, amount, n in q:
rec = receipts.get((mkt, acct, sid))
# What the receipt implies for the NEXT processing run.
if rec is not None:
will_receive = bool(rec.bank_date and month_end and rec.bank_date <= month_end)
elif (s.payout_mode or "auto") == "manual":
will_receive = False
else:
cutoff = (month_end - dt.timedelta(days=s.clearing_lag_days or 0)
if month_end else None)
d = amazon_date if isinstance(amazon_date, dt.date) else (
dt.date.fromisoformat(str(amazon_date)) if amazon_date else None)
will_receive = bool(d and cutoff and d <= cutoff)
out.append({
"marketplace": mkt,
"account_type": acct,
"settlement_id": sid,
"amazon_date": str(amazon_date) if amazon_date else None,
"amount": round(amount or 0.0, 2),
"rows": n,
"bank_date": rec.bank_date.isoformat() if rec and rec.bank_date else None,
"bank_amount": rec.bank_amount if rec else None,
"note": (rec.note if rec else "") or "",
"entered_by": (rec.entered_by if rec else "") or "",
"received_now": status.get((mkt, acct, sid)),
"received_next_run": will_receive,
})
out.sort(key=lambda r: (r["marketplace"], r["amazon_date"] or "", r["settlement_id"]))
return {
"payout_mode": s.payout_mode or "auto",
"clearing_lag_days": s.clearing_lag_days,
"month_end": month_end.isoformat() if month_end else None,
"needs_reprocess": bool(s.needs_reprocess),
"payouts": out,
}
class ReceiptIn(BaseModel):
marketplace: str
account_type: str
settlement_id: str
bank_date: str | None = None # None/"" removes the receipt
bank_amount: float | None = None
note: str = ""
entered_by: str = ""
@router.put("/{session_id}/payouts/receipts")
def put_receipts(session_id: int, items: list[ReceiptIn],
db: OrmSession = Depends(db_dep)) -> dict:
"""Batch upsert bank receipts. Only the payouts sent are touched; a null bank_date
deletes that payout's receipt (it reverts to the mode's default rule)."""
s = get_session_or_404(session_id, db)
if s.status == "processing":
raise HTTPException(409, "This closing is still processing — wait for it to finish.")
existing = {(r.marketplace, r.account_type, r.settlement_id): r
for r in db.query(models.PayoutReceipt).filter(
models.PayoutReceipt.session_id == session_id)}
saved = removed = 0
for it in items:
key = (it.marketplace, it.account_type, it.settlement_id)
row = existing.get(key)
if not it.bank_date:
if row is not None:
db.delete(row)
removed += 1
continue
try:
bank_date = dt.date.fromisoformat(it.bank_date)
except ValueError:
raise HTTPException(400, f"bank_date must be YYYY-MM-DD (got {it.bank_date!r}).")
if row is None:
row = models.PayoutReceipt(
session_id=session_id, marketplace=it.marketplace,
account_type=it.account_type, settlement_id=it.settlement_id)
db.add(row)
row.bank_date = bank_date
row.bank_amount = it.bank_amount
row.note = it.note or ""
row.entered_by = it.entered_by or ""
saved += 1
if saved or removed:
# The stored classification no longer reflects the receipts until a re-process.
s.needs_reprocess = True
db.commit()
return {"saved": saved, "removed": removed, "needs_reprocess": bool(s.needs_reprocess)}
class ModeIn(BaseModel):
mode: str
@router.put("/{session_id}/payouts/mode")
def put_mode(session_id: int, body: ModeIn, db: OrmSession = Depends(db_dep)) -> dict:
"""auto = bank date wins, clearing-lag fallback · manual = bank dates only, no heuristic."""
s = get_session_or_404(session_id, db)
if body.mode not in ("auto", "manual"):
raise HTTPException(400, "mode must be 'auto' or 'manual'.")
if s.status == "processing":
raise HTTPException(409, "This closing is still processing — wait for it to finish.")
if (s.payout_mode or "auto") != body.mode:
s.payout_mode = body.mode
s.needs_reprocess = True
db.commit()
return {"payout_mode": s.payout_mode, "needs_reprocess": bool(s.needs_reprocess)}

View File

@ -1,22 +1,31 @@
"""Read endpoints for the dashboard: summary, settlements, transactions, exceptions, etc.""" """Read endpoints for the dashboard: summary, settlements, transactions, exceptions, etc."""
from __future__ import annotations from __future__ import annotations
import datetime as dt
import json import json
from fastapi import APIRouter, Body, Depends, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session as OrmSession from sqlalchemy.orm import Session as OrmSession
from ...core.receivable import AGING_BANDS from ...core.receivable import AGING_BANDS, classify_aging
from ...core.settlements import RECEIVABLE_ACCOUNT_TYPES
from ...db import models from ...db import models
from ..deps import db_dep, get_session_or_404, to_dict from ..deps import blocked_payload, db_dep, get_session_or_404, is_blocked, to_dict
router = APIRouter(prefix="/api/sessions", tags=["results"]) router = APIRouter(prefix="/api/sessions", tags=["results"])
# Amazon closes a settlement roughly every two weeks; a receivable is not past due until that
# cycle plus the clearing lag has elapsed. Used only by the aging bands.
SETTLEMENT_CYCLE_DAYS = 14
@router.get("/{session_id}/summary") @router.get("/{session_id}/summary")
def summary(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: def summary(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
get_session_or_404(session_id, db) s = get_session_or_404(session_id, db)
# Headline receivable: withheld entirely while a month-end control is failing.
if is_blocked(s):
return blocked_payload(s)
setts = db.query(models.Settlement).filter(models.Settlement.session_id == session_id).all() setts = db.query(models.Settlement).filter(models.Settlement.session_id == session_id).all()
recon = db.query(models.ReconciliationRow).filter( recon = db.query(models.ReconciliationRow).filter(
models.ReconciliationRow.session_id == session_id).first() models.ReconciliationRow.session_id == session_id).first()
@ -34,7 +43,10 @@ def summary(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
receivable_settlements = [s for s in setts if s.status == "receivable"] receivable_settlements = [s for s in setts if s.status == "receivable"]
return { return {
"closing_receivable_usd": recon.final_receivable_usd if recon else None, "closing_receivable_usd": recon.final_receivable_usd if recon else None,
"reconciliation_status": recon.status if recon else None, # Self-consistency of the bucketing only — see core/reconciliation.py. The month-end
# controls (/controls) are what tells you whether the close can be trusted.
"bucket_identity_status": recon.status if recon else None,
"reconciliation_status": recon.status if recon else None, # deprecated alias
"reserve_total": recon.reserve_total if recon else 0.0, "reserve_total": recon.reserve_total if recon else 0.0,
"transfers_total": recon.transfers_total if recon else 0.0, "transfers_total": recon.transfers_total if recon else 0.0,
"receivable_orders": recon.receivable_orders if recon else 0.0, "receivable_orders": recon.receivable_orders if recon else 0.0,
@ -134,15 +146,30 @@ def reconciliation(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
@router.get("/{session_id}/journal") @router.get("/{session_id}/journal")
def journal(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: def journal(session_id: int, marketplace: str | None = None,
db: OrmSession = Depends(db_dep)) -> dict:
"""One marketplace's journal (default: the primary one), plus the sign-off state.
The sign-off (review approval) is per CLOSING, not per marketplace approving
publishes every marketplace's journal for the month to the Accounts Summary."""
from .ar import _journal_for, _market_list
get_session_or_404(session_id, db) get_session_or_404(session_id, db)
j = db.query(models.JournalEntry).filter( j = db.query(models.JournalEntry).filter(
models.JournalEntry.session_id == session_id).first() models.JournalEntry.session_id == session_id).first()
if not j or not j.data: if not j or not j.data:
return {"available": False} return {"available": False}
data = json.loads(j.data) payload = json.loads(j.data)
markets = _market_list(payload)
data, mkt = _journal_for(payload, marketplace)
data = dict(data) # never mutate the stored payload
data["available"] = True data["available"] = True
data["marketplace"] = mkt
data["marketplaces"] = markets
data["entry_no"] = j.entry_no or "" data["entry_no"] = j.entry_no or ""
data["reviewed_by"] = j.reviewed_by or ""
data["reviewed_at"] = j.reviewed_at.isoformat() if j.reviewed_at else None
data["approved_by"] = j.approved_by or ""
data["approved_at"] = j.approved_at.isoformat() if j.approved_at else None
return data return data
@ -158,16 +185,119 @@ def set_journal_entry_no(session_id: int, entry_no: str = Body(..., embed=True),
return {"entry_no": entry_no} return {"entry_no": entry_no}
def _journal_row_or_400(session_id: int, db: OrmSession) -> models.JournalEntry:
j = db.query(models.JournalEntry).filter(
models.JournalEntry.session_id == session_id).first()
if j is None or not j.data:
raise HTTPException(400, "Process the closing before signing off its journal entry.")
return j
@router.post("/{session_id}/journal/review")
def review_journal(session_id: int, name: str = Body(..., embed=True),
db: OrmSession = Depends(db_dep)) -> dict:
"""Step 1 of the sign-off: a person confirms they reviewed this month's journal."""
get_session_or_404(session_id, db)
if not name.strip():
raise HTTPException(400, "A reviewer name is required.")
j = _journal_row_or_400(session_id, db)
j.reviewed_by = name.strip()
j.reviewed_at = dt.datetime.utcnow()
db.commit()
return journal(session_id, None, db)
@router.post("/{session_id}/journal/approve")
def approve_journal(session_id: int, name: str = Body(..., embed=True),
db: OrmSession = Depends(db_dep)) -> dict:
"""Step 2: approval — this is what publishes the month to the Accounts Summary.
Requires a prior review, and a closing that isn't blocked by a month-end control:
an unverified number must never become part of the cross-month accounts view."""
s = get_session_or_404(session_id, db)
if not name.strip():
raise HTTPException(400, "An approver name is required.")
if is_blocked(s):
raise HTTPException(409, f"This closing is blocked by a failed month-end control — "
f"{s.blocked_reason}")
j = _journal_row_or_400(session_id, db)
if not j.reviewed_by:
raise HTTPException(400, "The journal must be reviewed before it can be approved.")
j.approved_by = name.strip()
j.approved_at = dt.datetime.utcnow()
db.commit()
return journal(session_id, None, db)
@router.post("/{session_id}/journal/reset-signoff")
def reset_journal_signoff(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
"""Withdraw the sign-off (removes the month from the Accounts Summary)."""
get_session_or_404(session_id, db)
j = _journal_row_or_400(session_id, db)
j.reviewed_by = ""
j.reviewed_at = None
j.approved_by = ""
j.approved_at = None
db.commit()
return journal(session_id, None, db)
@router.get("/{session_id}/aging") @router.get("/{session_id}/aging")
def aging(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: def aging(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
get_session_or_404(session_id, db) """
Real aging, banded by days **past due** not days since the transaction.
An Amazon receivable is not due when the order posts; it is due when its settlement
disburses. Amazon closes a settlement roughly every 14 days and the payout then takes the
clearing lag to land, so a settlement is only overdue once
`last activity + SETTLEMENT_CYCLE_DAYS + clearing_lag` has passed.
A healthy month therefore lands ~100% in Current, matching the Finance workbook. What
changes is that a settlement Amazon is actually holding dispute, account review, stuck
disbursement now ages into 1-30/31-60/61-90 instead of hiding inside Current, which is
the entire point of an aging report. (Banding by transaction date instead would push a
normal biweekly settlement into 1-30 and make the report meaningless.)
"""
s = get_session_or_404(session_id, db)
if is_blocked(s):
return blocked_payload(s)
rows = db.query(models.ReceivableResultRow).filter( rows = db.query(models.ReceivableResultRow).filter(
models.ReceivableResultRow.session_id == session_id, models.ReceivableResultRow.session_id == session_id,
models.ReceivableResultRow.account_type == "TOTAL").all() models.ReceivableResultRow.account_type == "TOTAL").all()
setts = db.query(models.Settlement).filter(
models.Settlement.session_id == session_id,
models.Settlement.status == "receivable").all()
month_end = s.month_end_date
# Local-currency band composition from the settlements that make up the receivable.
by_mkt: dict[str, dict[str, float]] = {}
lag = s.clearing_lag_days or 0
for st in setts:
if (st.account_type or "").strip().lower() not in RECEIVABLE_ACCOUNT_TYPES:
continue # transfers / unspecified aren't receivable
if month_end and st.last_date:
due = st.last_date + dt.timedelta(days=SETTLEMENT_CYCLE_DAYS + lag)
days_overdue = (month_end - due).days
else:
days_overdue = 0
band = classify_aging(days_overdue)
by_mkt.setdefault(st.marketplace, {b: 0.0 for b in AGING_BANDS})[band] += st.order_total
matrix = [] matrix = []
for r in rows: for r in rows:
band = {b: 0.0 for b in AGING_BANDS} local_bands = by_mkt.get(r.marketplace) or {b: 0.0 for b in AGING_BANDS}
band["Current"] = r.receivable_usd # Amazon receivable is all Current composed = sum(local_bands.values())
matrix.append({"marketplace": r.marketplace, **band, # The receivable is ROUND(reserve + additional sales); the reserve and that rounding
"Total": r.receivable_usd}) # belong to the current period, so the residual lands in Current and the row still
return {"bands": list(AGING_BANDS), "rows": matrix} # ties exactly to the headline receivable.
residual = (r.receivable_local or 0.0) - composed
rate = r.fx_rate or 1.0
band_usd = {b: round(v * rate, 2) for b, v in local_bands.items()}
band_usd["Current"] = round((local_bands["Current"] + residual) * rate, 2)
total = round(sum(band_usd.values()), 2)
matrix.append({"marketplace": r.marketplace, "currency": r.currency,
**band_usd, "Total": total})
return {"bands": list(AGING_BANDS), "rows": matrix,
"basis": (f"days past due at month-end — a settlement becomes due "
f"{SETTLEMENT_CYCLE_DAYS} days after its last activity plus the "
f"{lag}-day clearing lag")}

View File

@ -13,6 +13,17 @@ from ..deps import db_dep, get_session_or_404, to_dict
router = APIRouter(prefix="/api/sessions", tags=["settings"]) router = APIRouter(prefix="/api/sessions", tags=["settings"])
rules_router = APIRouter(prefix="/api/mapping-rules", tags=["mapping"]) rules_router = APIRouter(prefix="/api/mapping-rules", tags=["mapping"])
meta_router = APIRouter(prefix="/api", tags=["meta"])
@meta_router.get("/definitions")
def definitions() -> dict:
"""What every dashboard figure means: formula + source columns/types, per metric key.
Content lives in core/definitions.py, next to the engine code it documents, so the UI's
(i) buttons cannot drift from what the engine actually computes."""
from ...core.definitions import DEFINITIONS
return DEFINITIONS
class RuleIn(BaseModel): class RuleIn(BaseModel):
@ -98,11 +109,26 @@ def get_fx(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]:
@router.put("/{session_id}/fx") @router.put("/{session_id}/fx")
def put_fx(session_id: int, items: list[FxIn], db: OrmSession = Depends(db_dep)) -> list[dict]: def put_fx(session_id: int, items: list[FxIn], db: OrmSession = Depends(db_dep)) -> list[dict]:
get_session_or_404(session_id, db) s = get_session_or_404(session_id, db)
db.query(models.FxRate).filter(models.FxRate.session_id == session_id).delete() # Upsert per marketplace — NOT delete-all-then-insert. This used to wipe every rate not
# named in the body, so a partial PUT silently removed the other marketplaces' rates and
# the close fell back to the hardcoded Jan-26 defaults without a word.
existing = {r.marketplace: r for r in db.query(models.FxRate).filter(
models.FxRate.session_id == session_id)}
for it in items: for it in items:
db.add(models.FxRate(session_id=session_id, marketplace=it.marketplace, row = existing.get(it.marketplace)
currency=it.currency, rate=it.rate, source=it.source, if row is None:
rate_date=it.rate_date)) row = models.FxRate(session_id=session_id, marketplace=it.marketplace)
db.add(row)
# Changing a rate withdraws the confirmation that was given for the old one.
if row.rate != it.rate or (row.currency or "") != it.currency:
row.confirmed_by = ""
row.confirmed_at = None
row.confirmed_month = ""
row.currency, row.rate, row.source, row.rate_date = (
it.currency, it.rate, it.source, it.rate_date)
db.commit() db.commit()
if s.status in ("processed", "blocked", "completed"):
from ...services.controls_run import run_and_persist
run_and_persist(db, session_id)
return get_fx(session_id, db) return get_fx(session_id, db)

View File

@ -66,7 +66,7 @@ class CalamineReader:
# top-down row scan means the real localized header (row 8) beats any translation # top-down row scan means the real localized header (row 8) beats any translation
# helper row below it. # helper row below it.
best = None best = None
best_key = (-1, -1) best_score = -1
for name in self._wb.sheet_names: for name in self._wb.sheet_names:
sheet = self._wb.get_sheet_by_name(name) sheet = self._wb.get_sheet_by_name(name)
head = sheet.to_python(nrows=15) head = sheet.to_python(nrows=15)
@ -77,9 +77,12 @@ class CalamineReader:
continue continue
mapping = build_mapping(cells, r_idx + 1, self.saved_overrides) mapping = build_mapping(cells, r_idx + 1, self.saved_overrides)
if _DETECT_REQUIRED.issubset(set(mapping.field_to_col)): if _DETECT_REQUIRED.issubset(set(mapping.field_to_col)):
key = (len(mapping.field_to_col), self._safe_height(name)) # Tie-break MUST match TransactionReader exactly (score only, first wins),
if key > best_key: # or the two readers can select different worksheets from the same workbook
best, best_key = (name, sheet, r_idx, mapping), key # and produce different receivables. See tests/test_reader_equivalence.py.
score = len(mapping.field_to_col)
if score > best_score:
best, best_score = (name, sheet, r_idx, mapping), score
break # first qualifying row per sheet (the real header) break # first qualifying row per sheet (the real header)
if not best: if not best:
raise ParseError( raise ParseError(
@ -99,13 +102,23 @@ class CalamineReader:
self.file_meta.header_row = self.header_row self.file_meta.header_row = self.header_row
self.file_meta.unmapped_headers = mapping.unmapped self.file_meta.unmapped_headers = mapping.unmapped
self.file_meta.missing_required = mapping.missing_required self.file_meta.missing_required = mapping.missing_required
self.file_meta.duplicate_fields = mapping.duplicate_fields
self.file_meta.sheet_last_row = self._safe_height(name) # control C1
return mapping return mapping
def _safe_height(self, name: str) -> int: def _safe_height(self, name: str) -> int:
"""
1-based last row of a sheet, matching the worksheet's own <dimension> so control C1
agrees whichever reader ran.
python-calamine's `total_height` is the 0-BASED index of the last row, not a count
(a 21-row sheet reports 20), so it needs +1. Returns 0 for "unknown", which makes C1
skip the file rather than invent a discrepancy.
"""
try: try:
return self._wb.get_sheet_by_name(name).total_height # type: ignore[union-attr] return self._wb.get_sheet_by_name(name).total_height + 1 # type: ignore[union-attr]
except Exception: except Exception:
return 1 << 30 return 0
# -- records -- # -- records --
def iter_records(self, only_fields: set[str] | None = None) -> Iterator[dict]: def iter_records(self, only_fields: set[str] | None = None) -> Iterator[dict]:
@ -133,13 +146,15 @@ class CalamineReader:
has_value = False has_value = False
for fld, i in items: for fld, i in items:
v = row[i] if i < len(row) else None v = row[i] if i < len(row) else None
cv = _conv_cal(fld, v) rec[fld] = _conv_cal(fld, v)
rec[fld] = cv # Shared row-emptiness rule (must match TransactionReader exactly): a row counts
if cv not in (None, "", 0.0): # when any mapped column holds a NON-EMPTY SOURCE cell. Testing the *converted*
# value instead made every row qualify here — amount cells convert to 0.0 — so
# this reader emitted trailing blank rows the other reader dropped.
if v not in (None, ""):
has_value = True has_value = True
elif cv == 0.0:
has_value = True # a zero amount is still a real cell
if not has_value: if not has_value:
self.file_meta.blank_rows_skipped += 1
continue continue
# Localized EU reports have a Finance-added translation header right below the # Localized EU reports have a Finance-added translation header right below the
# real header — text where the settlement id / date belong. Skip and count it. # real header — text where the settlement id / date belong. Skip and count it.

View File

@ -332,6 +332,10 @@ class ColumnMapping:
unmapped: dict[str, str] = field(default_factory=dict) unmapped: dict[str, str] = field(default_factory=dict)
# required fields absent from the header row # required fields absent from the header row
missing_required: list[str] = field(default_factory=list) missing_required: list[str] = field(default_factory=list)
# Two headers resolved to the SAME canonical field: field -> [(col, header), ...].
# Only the first is used, so the second column's amounts would vanish from the journal.
# Surfaced as an error rather than silently demoted to `unmapped`.
duplicate_fields: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
header_row: int = 0 header_row: int = 0
@property @property
@ -364,6 +368,12 @@ def build_mapping(
if fld and fld not in m.field_to_col: if fld and fld not in m.field_to_col:
m.col_to_field[col] = fld m.col_to_field[col] = fld
m.field_to_col[fld] = col m.field_to_col[fld] = col
elif fld:
# Collision: first column wins and this one is dropped. Record both so the close
# can raise, instead of quietly excluding a whole amount column.
first_col = m.field_to_col[fld]
m.duplicate_fields.setdefault(fld, [(first_col, "")]).append((col, str(text)))
m.unmapped[col] = str(text)
elif text and str(text).strip(): elif text and str(text).strip():
m.unmapped[col] = str(text) m.unmapped[col] = str(text)
m.missing_required = [f for f in REQUIRED_FIELDS if f not in m.field_to_col] m.missing_required = [f for f in REQUIRED_FIELDS if f not in m.field_to_col]

View File

@ -0,0 +1,276 @@
"""
Month-end controls.
Why this module exists
----------------------
The close previously reported "Reconciled" from a single identity:
uploaded_total == receivable_orders + paid_orders + transfers_total
That identity cannot fail. `uploaded_total` is accumulated from the same parsed record
stream that fills the three buckets, and every record lands in exactly one of them, so the
two sides are the same sum written twice. It reported "Reconciled" on the Jan-2026 close
while the Reconciliation Control was understating the group receivable by USD 444,658.44,
and it would report "Reconciled" just as happily if an entire marketplace file failed to
parse because a file that yields no rows contributes zero to *both* sides.
Every control below is instead **independent of the thing it checks**: it compares the
engine's output against something the engine did not produce (the worksheet's own declared
extent, the source `total` column, the FX rates a human confirmed, a second calculation
method). A control that cannot fail is not a control.
Severity `error` blocks the close: no receivable figure is released to the dashboard or to
an export until it is resolved. A number that cannot be trusted is never shown a missing
number cannot be posted to the ledger, a wrong one can.
"""
from __future__ import annotations
from dataclasses import dataclass, field
# Roll-forward vs settlement method may legitimately differ by rounding across marketplaces.
DUAL_METHOD_TOLERANCE = 1.0
# Σ(journal lines) vs Σ(total): float accumulation over millions of rows, not a real gap.
COMPONENT_TOLERANCE = 0.05
PASS, FAIL, NA = "pass", "fail", "not_applicable"
@dataclass
class ControlResult:
key: str
label: str
status: str = PASS
severity: str = "error"
detail: str = ""
evidence: list[str] = field(default_factory=list)
@property
def blocking(self) -> bool:
return self.status == FAIL and self.severity == "error"
def _ok(key: str, label: str, detail: str) -> ControlResult:
return ControlResult(key=key, label=label, status=PASS, detail=detail)
def _na(key: str, label: str, detail: str) -> ControlResult:
return ControlResult(key=key, label=label, status=NA, severity="info", detail=detail)
def _fail(key: str, label: str, detail: str, evidence: list[str],
severity: str = "error") -> ControlResult:
return ControlResult(key=key, label=label, status=FAIL, severity=severity,
detail=detail, evidence=evidence[:10])
# --------------------------------------------------------------------------- C1
def c1_source_row_count(file_metas) -> ControlResult:
"""
Every data row the worksheet declares must be either imported or deliberately skipped.
Independent because the expected count comes from the worksheet's own <dimension>, read
from the file header not from our row stream. This is the control that distinguishes
"this marketplace had no transactions" from "this marketplace's file failed to parse",
which otherwise produce an identical, silent zero.
"""
key, label = "C1", "Source row count"
checked, unknown, bad = 0, [], []
for m in file_metas:
if not getattr(m, "sheet_last_row", 0):
unknown.append(m.filename)
continue
checked += 1
expected = m.expected_data_rows
got = m.rows_accounted_for
if got != expected:
bad.append(
f"{m.filename}: worksheet declares {expected:,} data rows "
f"(rows 1..{m.sheet_last_row}, header row {m.header_row}) but "
f"{got:,} were accounted for "
f"(imported {m.imported_rows:,}, helper {m.helper_rows_skipped:,}, "
f"blank {m.blank_rows_skipped:,}) — {expected - got:+,} unexplained")
if bad:
return _fail(key, label,
f"{len(bad)} file(s) consumed a different number of rows than the "
f"worksheet declares. Rows may have been dropped during parsing.", bad)
if not checked:
return _na(key, label, "No worksheet declared its extent; row counts unverifiable.")
note = f"{checked} file(s) fully accounted for."
if unknown:
note += f" {len(unknown)} file(s) declared no extent: {', '.join(unknown[:3])}."
return _ok(key, label, note)
# --------------------------------------------------------------------------- C2
def c2_column_completeness(journal_payload: dict, uploaded_total: float) -> ControlResult:
"""
Every amount column must be accounted for: Σ(journal GL lines) == Σ(`total` column).
Independent because the journal is built by summing the individual component columns
(product sales, fees, tax, ) while `total` is Amazon's own pre-computed column AD. If a
column is unmapped, mapped twice, or a new column appears, the two sides separate. This
is what catches an Australia-style `fulfilment by amazon fees` alias gap, and unlike the
old identity it is a genuine cross-check between two different numbers.
"""
key, label = "C2", "Column completeness"
if not journal_payload or not journal_payload.get("lines"):
return _na(key, label, "No journal decomposition available.")
per_market = journal_payload.get("per_marketplace") or {
journal_payload.get("marketplace", "USA"): journal_payload}
bad, total_lines = [], 0.0
for mkt, jr in per_market.items():
s = sum(l.get("total", 0.0) for l in jr.get("lines", []))
total_lines += s
delta = round(uploaded_total - total_lines, 2)
if abs(delta) > COMPONENT_TOLERANCE:
for mkt, jr in per_market.items():
s = sum(l.get("total", 0.0) for l in jr.get("lines", []))
bad.append(f"{mkt}: GL lines sum to {s:,.2f}")
return _fail(key, label,
f"Sum of GL lines {total_lines:,.2f} != sum of the source `total` column "
f"{uploaded_total:,.2f} (difference {delta:,.2f}). An amount column is "
f"unmapped, mapped twice, or newly added by Amazon.", bad)
return _ok(key, label,
f"GL lines reconcile to the source `total` column "
f"({total_lines:,.2f}, difference {delta:,.2f}).")
# --------------------------------------------------------------------------- C3
def c3_bucket_completeness(agg) -> ControlResult:
"""
Every money-carrying order row must be placeable in a receivable bucket.
A row whose `account type` is unrecognized fails the receivable SUMIFS filter; a row
whose settlement id is not numeric sorts below every boundary and is classified "paid".
Both silently REDUCE the receivable, and neither previously raised anything.
"""
key, label = "C3", "Bucket completeness"
problems, evidence = [], []
if agg.unclassified_acct_count:
problems.append(
f"{agg.unclassified_acct_count:,} row(s) carrying "
f"{agg.unclassified_acct_total:,.2f} have an unrecognized account type")
evidence += agg.unclassified_acct_samples
if agg.unclassified_sid_count:
problems.append(
f"{agg.unclassified_sid_count:,} row(s) carrying "
f"{agg.unclassified_sid_total:,.2f} have a blank or non-numeric settlement id")
evidence += agg.unclassified_sid_samples
if problems:
return _fail(key, label,
"; ".join(problems) + ". These are excluded from the receivable — "
"map or correct them before relying on this close.", evidence)
return _ok(key, label, "Every order row resolves to a receivable bucket.")
# --------------------------------------------------------------------------- C4
def c4_dual_method(rows: list[dict], openings_all_zero: bool = False) -> ControlResult:
"""
The two independent closing methods must agree, per marketplace, in LOCAL currency:
settlement method : ROUND(reserve + additional sales)
roll-forward : opening AR balance + net revenue - payouts received
Independent because they share no arithmetic one filters open settlements, the other
accumulates GL movement off an opening balance. A gap means the opening balance, a
reserve, or a timing item is wrong.
Reported as a warning rather than a block, because the opening balance is a Finance input
the engine cannot derive: until it is entered (or carried forward from the prior closing)
the roll-forward is measuring only this month's movement and MUST differ. That case is
called out explicitly "no openings entered yet" is a different situation from "openings
are entered and the two methods still disagree", and only the second is a real problem.
"""
key, label = "C4", "Dual-method agreement"
if not rows:
return _na(key, label, "No per-marketplace movement available.")
bad = []
for r in rows:
s, c = r.get("settlement_closing"), r.get("closing_local")
if s is None or c is None:
continue
d = round(c - s, 2)
if abs(d) > DUAL_METHOD_TOLERANCE:
bad.append(f"{r['marketplace']}: roll-forward {c:,.2f} vs settlement {s:,.2f} "
f"({r.get('currency', '')}) — difference {d:,.2f}")
if bad:
if openings_all_zero:
detail = (f"Every opening AR balance on this closing is zero, so the roll-forward "
f"({len(bad)} marketplace(s) differing) is only measuring this month's "
f"movement — not the receivable actually outstanding. The settlement "
f"figure is the reliable one until opening balances are entered. "
f"Carry them forward from the prior closing, or enter them from last "
f"month's workbook, on the Opening Balances tab.")
else:
detail = (f"{len(bad)} marketplace(s) disagree between the two closing methods "
f"even with opening balances entered. Review the opening balance, the "
f"reserve, or a month-boundary timing item for each one below.")
return _fail(key, label, detail, bad, severity="warning")
return _ok(key, label, f"Both methods agree across {len(rows)} marketplace(s).")
# --------------------------------------------------------------------------- C5
def c5_fx_confirmed(fx_rows, reporting_month: str, markets: list[str]) -> ControlResult:
"""
Every non-USD marketplace must have an FX rate a human confirmed FOR THIS MONTH.
The seeded `DEFAULT_FX_USD` table is a January-2026 snapshot. Left as a default it would
value a July close at January's rates and say nothing, so a seeded-but-unconfirmed rate
counts as missing.
"""
key, label = "C5", "FX rates confirmed"
by_mkt = {r.marketplace: r for r in fx_rows}
missing = []
for mkt in markets:
row = by_mkt.get(mkt)
if row is None:
missing.append(f"{mkt}: no FX rate recorded")
continue
if (row.currency or "USD").upper() == "USD" and float(row.rate or 1.0) == 1.0:
continue # USD at parity needs no confirmation
if not row.confirmed_by:
missing.append(f"{mkt}: rate {row.rate} ({row.currency}) is a seeded default "
f"[{row.source or 'unknown source'}] — not confirmed by anyone")
elif (row.confirmed_month or "") != reporting_month:
missing.append(f"{mkt}: rate {row.rate} ({row.currency}) was confirmed for "
f"{row.confirmed_month or 'an unknown month'}, not {reporting_month}")
if missing:
return _fail(key, label,
f"{len(missing)} marketplace(s) have no FX rate confirmed for "
f"{reporting_month}. Converted totals would use stale rates.", missing)
return _ok(key, label, f"FX confirmed for {reporting_month} across all marketplaces.")
# --------------------------------------------------------------------------- C6
def c6_currency_integrity(control_total_usd: float | None,
all_markets_total_usd: float | None) -> ControlResult:
"""
Two surfaces roll the marketplaces up to a group total; they must agree to the cent.
Independent because they are separate code paths over the same data. Before the fix the
Reconciliation Control added the local-currency closings together (USD + EUR + GBP + PLN
+ SEK + CAD + AUD as one figure) while the All-Markets tab converted properly a
USD 444,658.44 gap on Jan-2026, on the figure that gates sign-off.
"""
key, label = "C6", "Currency integrity"
if control_total_usd is None or all_markets_total_usd is None:
return _na(key, label, "Group totals not available.")
d = round(control_total_usd - all_markets_total_usd, 2)
if abs(d) > 0.01:
return _fail(key, label,
f"Group closing receivable differs between surfaces: Reconciliation "
f"Control {control_total_usd:,.2f} vs All Markets "
f"{all_markets_total_usd:,.2f} (difference {d:,.2f} USD). One of them is "
f"adding currencies without converting.",
[f"difference {d:,.2f} USD"])
return _ok(key, label,
f"Both group roll-ups agree at {control_total_usd:,.2f} USD.")
def blocking_summary(results: list[ControlResult]) -> str:
"""One-line reason a close is blocked, or "" when nothing blocks."""
blockers = [r for r in results if r.blocking]
if not blockers:
return ""
return " | ".join(f"{r.key} {r.label}: {r.detail}" for r in blockers)

View File

@ -0,0 +1,270 @@
"""
Plain-language definitions of every figure the dashboard shows served to the UI as the
content of the (i) info buttons.
These live in the backend, next to the engine, ON PURPOSE: each entry describes what
`journal._contribute_components`, `movement.compute_movement`, `receivable.py` and
`settlements.py` actually do, and anyone changing that code is looking at the file that
documents it. If you change a rule, change its definition in the same commit.
Shape per entry:
formula : the arithmetic, in one line
source : which source-report columns / transaction types feed it
note : anything Finance should know when tying it out (optional)
Column names in `source` are the Amazon report's own headers (localized headers map to
these via column_map.py).
"""
from __future__ import annotations
REFUND_TYPES_LABEL = "Refund, Refund_Retrocharge, Chargeback Refund, A-to-z Guarantee Claim"
DEFINITIONS: dict[str, dict[str, str]] = {
# ------------------------------------------------- revenue components (journal.py)
"product_sales": {
"formula": "Σ `product sales`",
"source": f"the `product sales` column of every row except Transfers and "
f"refund-type rows ({REFUND_TYPES_LABEL})",
"note": "Refund-type rows' product sales are shown in the Refunds line instead, "
"so sales and refunds are visible separately.",
},
"shipping_credits": {
"formula": "Σ `shipping credits`",
"source": "the `shipping credits` column of non-refund, non-transfer rows",
},
"gift_wrap_credits": {
"formula": "Σ `gift wrap credits`",
"source": "the `gift wrap credits` column of non-refund, non-transfer rows",
},
"other_sales_credits": {
"formula": "Σ `regulatory fee`",
"source": "the `regulatory fee` column of every non-transfer row",
},
"refunds": {
"formula": "Σ (`product sales` + `shipping credits` + `gift wrap credits`) over refund rows",
"source": f"rows whose type is one of: {REFUND_TYPES_LABEL}",
"note": "Negative: money returned to buyers.",
},
"promotional_rebates": {
"formula": "Σ `promotional rebates`",
"source": "the `promotional rebates` column of every non-transfer row",
},
"tax_net": {
"formula": "Σ (`product sales tax` + `shipping credits tax` + `gift wrap credits tax` "
"+ `tax on regulatory fee` + `promotional rebates tax` "
"+ `marketplace withheld tax` + `sales tax collected`)",
"source": "every tax column, netted together",
"note": "Where Amazon collects and remits the tax itself, collected and withheld "
"cancel and this nets to ~0.",
},
"selling_fees": {
"formula": "Σ `selling fees`",
"source": "the `selling fees` column (referral commissions, variable closing fees)",
},
"fba_fees": {
"formula": "Σ `fba fees`",
"source": "the `fba fees` / `fulfilment by amazon fees` column (pick & pack, weight handling)",
},
"storage_fees": {
"formula": "Σ `other` over FBA Inventory Fee rows",
"source": "rows whose type is `FBA Inventory Fee` (monthly + long-term storage); "
"the amount sits in the `other` column",
},
"advertising": {
"formula": "Σ (`other` + `other transaction fees`) over advertising rows",
"source": "rows whose DESCRIPTION reads as advertising — \"Cost of advertising\", "
"\"Sponsored Products\", Werbekosten, publicité, pubblicità, … — regardless "
"of type (Amazon books these as plain `Service Fee`)",
"note": "The amount column differs by marketplace: `other` in the North-America "
"report, `other transaction fees` in the UK/EU report. Both are captured.",
},
"other_transaction_fees": {
"formula": "Σ `other transaction fees` (excluding advertising rows)",
"source": "the `other transaction fees` column — chargebacks, shipping holdbacks — "
"minus the rows identified as advertising, which move to the "
"Advertising line",
},
"adjustments": {
"formula": "Σ `other` over Adjustment rows",
"source": "rows of type `Adjustment` (FBA inventory reimbursements, buyer "
"recharges); the amount sits in the `other` column",
},
"freight": {
"formula": "Σ `other` over Shipping Services rows",
"source": "rows of type `Shipping Services` — outward freight billed by Amazon",
},
"other_service_charges": {
"formula": "Σ `other` over the remaining rows",
"source": "the `other` column of rows not classified as storage, freight, "
"advertising, or adjustment (e.g. subscription fees)",
"note": "If this is unexpectedly large, open Transaction Details and check the "
"descriptions — a new Amazon charge type may deserve its own line.",
},
"transfers": {
"formula": "Σ `total` over Transfer rows",
"source": "rows of type `Transfer` — Amazon's bank payouts (negative = paid out to us)",
},
"liquidations": {
"formula": "Σ `total` over Liquidations rows — memo only",
"source": "rows of type `Liquidations` / `Liquidations Adjustments`",
"note": "Already included in the lines above; shown separately for visibility, "
"never added twice.",
},
# ------------------------------------------------- aggregate lines
"gross_revenue": {
"formula": "Order/product sales + Shipping credits + Gift-wrap credits "
"+ Other sales credits + Refunds",
"source": "the revenue-group lines above",
},
"net_revenue": {
"formula": "Gross revenue + every fee line (rebates, tax, selling, FBA, storage, "
"advertising, other fees, adjustments, freight, other services)",
"source": "every line above except Transfers — i.e. all activity except bank payouts",
"note": "Equals the month's accrued receivable movement before payouts.",
},
"opening_balance": {
"formula": "prior month's closing receivable",
"source": "carried forward from the prior closing, or entered from last month's "
"workbook on the Opening Balances tab",
"note": "Zero on a first-ever closing. Until entered, the roll-forward measures "
"only this month's movement (see control C4).",
},
"disbursements": {
"formula": "Σ payouts RECEIVED by month-end",
"source": "a payout counts as received from its BANK-receipt date when Finance has "
"entered one (received ⇔ bank date ≤ month-end); otherwise the "
"clearing-lag heuristic on Amazon's transfer date (auto mode) or not at "
"all (manual mode)",
"note": "Amazon's Transfer date is when the payout was initiated — the bank credit "
"lands 3-5 working days later. Enter bank dates on the AR Ledger tab.",
},
"in_transit_payouts": {
"formula": "Σ payouts NOT received by month-end",
"source": "payouts Amazon initiated whose bank credit had not arrived by month-end; "
"their settlements stay in the receivable",
},
"bank_receipt": {
"formula": "received ⇔ bank date ≤ month-end",
"source": "the date (and amount) Finance records when a payout lands in the bank "
"account — overrides the clearing-lag heuristic for that payout",
"note": "auto mode: payouts without a receipt fall back to the clearing-lag "
"heuristic. manual mode: a payout without a receipt is NOT received. "
"Changes apply when the closing is re-processed. If the bank amount "
"differs from Amazon's payout, a variance warning is raised on the "
"Exceptions tab; the ledger keeps Amazon's amount.",
},
"closing_receivable": {
"formula": "Opening AR balance + Net revenue Payouts received",
"source": "the roll-forward method — cross-checked against the settlement method "
"(control C4)",
},
"settlement_closing": {
"formula": "ROUND(reserve + additional sales) per marketplace",
"source": "`additional sales` = Σ `total` over open (unpaid) settlements, for real "
"order account types, excluding Transfers — the workbook's SUMIFS method",
"note": "This is the method the Finance workbook uses (USA Jan-26 = 11,110,433) and "
"the benchmark figure. FX converts it to USD per marketplace.",
},
"reserve": {
"formula": "Opening + Sales Receipts Refunds Expenses over PAID settlements (≈ 0)",
"source": "a small Finance-maintained reconciliation carry entered per marketplace",
},
# ------------------------------------------------- overview / other tabs
"closing_receivable_usd": {
"formula": "Σ over marketplaces of ROUND(reserve + additional sales) × FX rate, "
"+ manual adjustments",
"source": "the settlement method per marketplace, converted at the session's "
"confirmed FX rates",
},
"receivable_orders": {
"formula": "Σ `total` over rows in RECEIVABLE settlements",
"source": "non-transfer rows of settlements not yet paid out by the cutoff",
},
"paid_orders": {
"formula": "Σ `total` over rows in PAID settlements",
"source": "non-transfer rows of settlements whose payout reached the bank by the cutoff",
},
"transfers_total": {
"formula": "Σ `total` over every Transfer row",
"source": "all bank payouts in the uploaded files, received or in transit",
},
"settlement_status": {
"formula": "receivable ⇔ settlement id ≥ the paid boundary",
"source": "a settlement stays receivable until the Transfer that pays it out is "
"dated on/before month-end clearing-lag; the boundary is the newest "
"settlement with a received payout",
},
"aging_basis": {
"formula": "days past DUE at month-end; due = last activity + 14-day settlement "
"cycle + clearing lag",
"source": "each receivable settlement's last activity date",
"note": "Amazon settles ~biweekly, so a healthy month is ~100% Current. A "
"settlement Amazon is holding ages into 1-30/31-60/61-90.",
},
# ------------------------------------------------- journal entry (GL lines)
"journal_entry": {
"formula": "Dr fees & refunds · Cr sales & tax · balancing Dr A/R = net revenue",
"source": "the month-end ACCRUAL entry. Positive line totals are credits, negative "
"are debits, so debits always equal credits",
"note": "Bank receipts (Transfer) are posted separately from bank statements and are "
"deliberately not part of this entry. Approval publishes the month to the "
"Accounts Summary; re-processing withdraws the sign-off.",
},
"Sales": {
"formula": "Σ (`product sales` + `shipping credits` + `gift wrap credits`) over non-refund rows",
"source": "gross sales value of every order-type row — Credit",
},
"Refunds": {
"formula": "Σ (`product sales` + `shipping credits` + `gift wrap credits`) over refund rows",
"source": f"rows of type {REFUND_TYPES_LABEL} — Debit",
},
"Tax": {
"formula": "Σ of every tax column, netted",
"source": "product/shipping/gift-wrap sales tax + regulatory + promotional-rebate tax "
"+ marketplace-withheld + collected — nets ≈ 0 where Amazon remits",
},
"FBA Selling Fee": {
"formula": "Σ `selling fees`",
"source": "referral commissions and variable closing fees — Debit",
},
"FBA Storage": {
"formula": "Σ `other` over FBA Inventory Fee rows",
"source": "monthly + long-term storage fees — Debit",
},
"FBA Fee": {
"formula": "Σ (`fba fees` + `promotional rebates` + non-advertising `other transaction "
"fees` + `regulatory fee` + unclassified `other`)",
"source": "the catch-all Amazon fee/adjustment bucket — Debit",
"note": "Advertising is broken out to its own line and is NOT in here.",
},
"Advertising Cost": {
"formula": "Σ (`other` + `other transaction fees`) over advertising rows",
"source": "rows whose description reads as advertising (\"Cost of advertising\", "
"Sponsored Products, localized variants) — Debit",
"note": "Amazon books these as plain `Service Fee`; the description is the only "
"signal, and the amount column differs by region.",
},
"Inventory Adjustment": {
"formula": "manual sheet line (0 unless classified)",
"source": "inventory adjustments booked to Sales:Inventory Adjustments",
},
"Outward Freight / Shipping": {
"formula": "Σ `other` over Shipping Services rows",
"source": "outward freight billed by Amazon — Debit",
},
"Receivable": {
"formula": "−Σ(all lines above) = net revenue",
"source": "the balancing figure of the accrual entry — Dr Accounts Receivable",
"note": "Equals the AR Ledger's net revenue for the month; bank receipts then "
"credit A/R as they arrive.",
},
"uploaded_total": {
"formula": "Σ `total` over every row of every uploaded file",
"source": "identical to receivable + paid + transfers by construction — a "
"self-consistency figure, NOT a control (see the Controls tab for the "
"checks that can actually fail)",
},
}

View File

@ -163,3 +163,33 @@ def is_storage_like(type_en: str | None, description: str | None) -> bool:
return True return True
hay = fold(f"{type_en or ''} {description or ''}") hay = fold(f"{type_en or ''} {description or ''}")
return any(k in hay for k in _STORAGE_KEYS_FOLDED) return any(k in hay for k in _STORAGE_KEYS_FOLDED)
# ---------------------------------------------------------------------------
# Advertising detection.
#
# Amazon does NOT give advertising its own transaction type. It books it as `type = "Service
# Fee"` with the intent only in the `description` column ("Cost of advertising"), so matching
# on the type alone finds nothing and every advertising charge silently lands in "Other
# service charges" — Canada Jan-2026 had CA$36,948.43 sitting there with a 0.00 advertising
# line. The description is the only signal, exactly as with storage fees above.
# ---------------------------------------------------------------------------
ADVERTISING_KEYWORDS = [
"advertis", # advertising / advertisement (EN)
"sponsored products", "sponsored brands", "sponsored display",
"publicite", "cout de la publicite", # FR
"werbung", "werbekosten", # DE
"pubblicita", "costo della pubblicita", # IT
"publicidad", "coste de publicidad", # ES
"advertentie", "advertentiekosten", # NL
"reklamy", "reklama", "koszt reklamy", # PL
"annonsering", "annonskostnad", # SV
"reklam", # TR / SV
]
_ADVERTISING_KEYS_FOLDED = [fold(k) for k in ADVERTISING_KEYWORDS]
def is_advertising_like(type_en: str | None, description: str | None) -> bool:
"""True when the type/description reads as an advertising charge."""
hay = fold(f"{type_en or ''} {description or ''}")
return any(k in hay for k in _ADVERTISING_KEYS_FOLDED)

View File

@ -18,15 +18,17 @@ from typing import Iterable
from .readers import make_reader from .readers import make_reader
from .regions import region_for from .regions import region_for
from .i18n import normalize_type from .i18n import is_advertising_like, normalize_type
# Refund-like transaction types (their sales/shipping land in the Refunds line). # Refund-like transaction types (their sales/shipping land in the Refunds line).
REFUND_TYPES = {"refund", "refund_retrocharge", "chargeback refund", "a-to-z guarantee claim"} REFUND_TYPES = {"refund", "refund_retrocharge", "chargeback refund", "a-to-z guarantee claim"}
# (key, GL account) in display order — matches the manual journal-entry sheet exactly. # (key, GL account) in display order. "Amazon USA" in an account name is a PLACEHOLDER —
# to_dict() substitutes the journal's actual marketplace (Amazon Canada, Amazon UK, …).
# "FBA Fee" is the catch-all Amazon fee/adjustment bucket (fba_fees + promotional rebates + # "FBA Fee" is the catch-all Amazon fee/adjustment bucket (fba_fees + promotional rebates +
# other transaction fees + all non-storage/-shipping "other" transactions), which is how the # other transaction fees + non-storage/-shipping/-advertising "other"); advertising is broken
# Finance sheet books it. Receivable is derived, not accumulated. # out to its own line since Amazon only marks it in the description. Receivable is derived,
# not accumulated.
LINE_ACCOUNTS: list[tuple[str, str]] = [ LINE_ACCOUNTS: list[tuple[str, str]] = [
("Sales", "Sales:All Platforms Sales:Amazon USA"), ("Sales", "Sales:All Platforms Sales:Amazon USA"),
("Refunds", "Sales:Refunds Given on Amazon:Amazon USA"), ("Refunds", "Sales:Refunds Given on Amazon:Amazon USA"),
@ -34,12 +36,19 @@ LINE_ACCOUNTS: list[tuple[str, str]] = [
("FBA Selling Fee", "Selling Fees and Commissions:Amazon USA"), ("FBA Selling Fee", "Selling Fees and Commissions:Amazon USA"),
("FBA Storage", "FBA Fees:Amazon USA Storage Fees"), ("FBA Storage", "FBA Fees:Amazon USA Storage Fees"),
("FBA Fee", "FBA Fees:Amazon USA FBA Fees"), ("FBA Fee", "FBA Fees:Amazon USA FBA Fees"),
("Advertising Cost", "Advertising Expense:Amazon USA Ads"),
("Inventory Adjustment", "Sales:Inventory Adjustments:Amazon USA"), ("Inventory Adjustment", "Sales:Inventory Adjustments:Amazon USA"),
("Outward Freight / Shipping", "Outward Freight/ Shipping Expense"), ("Outward Freight / Shipping", "Outward Freight/ Shipping Expense"),
("Transfer", "Amazon USA (bank clearing)"), ("Transfer", "Amazon USA (bank clearing)"),
] ]
LINE_KEYS = [k for k, _ in LINE_ACCOUNTS] LINE_KEYS = [k for k, _ in LINE_ACCOUNTS]
RECEIVABLE_KEY = "Receivable" RECEIVABLE_KEY = "Receivable"
# The month-end accrual entry books revenue & fees with A/R as the balancing figure; bank
# receipts (Transfer) are posted separately from bank statements. The UI's journal therefore
# hides the Transfer line and balances to `receivable_accrual` (= net revenue, Dr A/R).
# The Transfer line itself must stay in the payload: movement.compute_movement derives net
# revenue by skipping it, and control C2 sums every line against the source `total` column.
ACCRUAL_DISPLAY_EXCLUDES = ("Transfer",)
# Granular revenue/fee components for the Finance Summary (req #3): the journal's 9 GL lines # Granular revenue/fee components for the Finance Summary (req #3): the journal's 9 GL lines
# fold several of these together, so we accumulate them separately as well. They sum to the # fold several of these together, so we accumulate them separately as well. They sum to the
@ -104,17 +113,27 @@ def _contribute_components(rec: dict, comp: dict[str, float], ttype: str) -> Non
comp["promotional_rebates"] += _amt(rec, "promotional_rebates") comp["promotional_rebates"] += _amt(rec, "promotional_rebates")
comp["selling_fees"] += _amt(rec, "selling_fees") comp["selling_fees"] += _amt(rec, "selling_fees")
comp["fba_fees"] += _amt(rec, "fba_fees") comp["fba_fees"] += _amt(rec, "fba_fees")
comp["other_transaction_fees"] += _amt(rec, "other_transaction_fees")
# Advertising has no transaction type of its own: Amazon books it as "Service Fee" with
# the intent only in the DESCRIPTION ("Cost of advertising" / localized), and the amount
# lands in a different column per marketplace — `other` in the North-America schema
# (Canada Jan-26: -36,948.43), `other transaction fees` in the UK/EU schema (UK Jan-26:
# -153,176.09). Testing the type alone left every one of these in a catch-all bucket
# with the Advertising line reading 0.00.
advertising = is_advertising_like(ttype, rec.get("description"))
otf = _amt(rec, "other_transaction_fees")
if otf:
comp["advertising" if advertising else "other_transaction_fees"] += otf
o = _amt(rec, "other") o = _amt(rec, "other")
if o: if o:
if ttype == "FBA Inventory Fee": if ttype == "FBA Inventory Fee":
comp["storage_fees"] += o comp["storage_fees"] += o
elif ttype == "Shipping Services": elif ttype == "Shipping Services":
comp["freight"] += o comp["freight"] += o
elif advertising:
comp["advertising"] += o
elif ttype == "Adjustment": elif ttype == "Adjustment":
comp["adjustments"] += o comp["adjustments"] += o
elif "advertis" in tl:
comp["advertising"] += o
else: else:
comp["other_service_charges"] += o comp["other_service_charges"] += o
@ -130,15 +149,25 @@ def _contribute(rec: dict, acc: dict[str, float], ttype: str) -> None:
acc["Refunds" if tl in REFUND_TYPES else "Sales"] += gross acc["Refunds" if tl in REFUND_TYPES else "Sales"] += gross
acc["Tax"] += _tax_sum(rec) acc["Tax"] += _tax_sum(rec)
acc["FBA Selling Fee"] += _amt(rec, "selling_fees") acc["FBA Selling Fee"] += _amt(rec, "selling_fees")
# FBA Fee = fba fees + promotional rebates + other transaction fees + non-storage/-shipping "other". # Advertising gets its own GL line. Amazon books it as "Service Fee" with the intent only
# in the description, and the amount column differs by region (`other` in North America,
# `other transaction fees` in UK/EU) — same detection as the Finance Summary component.
advertising = is_advertising_like(ttype, rec.get("description"))
otf = _amt(rec, "other_transaction_fees")
if advertising and otf:
acc["Advertising Cost"] += otf
otf = 0.0
# FBA Fee = fba fees + promotional rebates + remaining other transaction fees + regulatory.
acc["FBA Fee"] += (_amt(rec, "fba_fees") + _amt(rec, "promotional_rebates") acc["FBA Fee"] += (_amt(rec, "fba_fees") + _amt(rec, "promotional_rebates")
+ _amt(rec, "other_transaction_fees") + _amt(rec, "regulatory_fee")) + otf + _amt(rec, "regulatory_fee"))
o = _amt(rec, "other") o = _amt(rec, "other")
if o: if o:
if ttype == "FBA Inventory Fee": if ttype == "FBA Inventory Fee":
acc["FBA Storage"] += o acc["FBA Storage"] += o
elif ttype == "Shipping Services": elif ttype == "Shipping Services":
acc["Outward Freight / Shipping"] += o acc["Outward Freight / Shipping"] += o
elif advertising:
acc["Advertising Cost"] += o
else: else:
acc["FBA Fee"] += o acc["FBA Fee"] += o
@ -173,6 +202,16 @@ class JournalResult:
return sum(p.receivable for p in self.periods) return sum(p.receivable for p in self.periods)
def to_dict(self) -> dict: def to_dict(self) -> dict:
def gl(name: str) -> str:
# LINE_ACCOUNTS carries "Amazon USA" as a placeholder — substitute the journal's
# actual marketplace so Canada books to "…:Amazon Canada", UK to "…:Amazon UK", …
return name.replace("Amazon USA", f"Amazon {self.marketplace}")
# The accrual balancing figure: −Σ(lines except Transfer) = net revenue, i.e. the
# month's Dr to Accounts Receivable. Bank receipts are posted separately.
def accrual_of(p: JournalPeriod) -> float:
return -sum(v for k, v in p.lines.items() if k not in ACCRUAL_DISPLAY_EXCLUDES)
return { return {
"marketplace": self.marketplace, "marketplace": self.marketplace,
"periods": [{"key": p.key, "label": p.label, "periods": [{"key": p.key, "label": p.label,
@ -180,14 +219,20 @@ class JournalResult:
"max_date": p.max_date.isoformat() if p.max_date else None} "max_date": p.max_date.isoformat() if p.max_date else None}
for p in self.periods], for p in self.periods],
"lines": [ "lines": [
{"key": k, "gl_account": acc, {"key": k, "gl_account": gl(acc),
"values": [round(p.lines[k], 2) for p in self.periods], "values": [round(p.lines[k], 2) for p in self.periods],
"total": round(self.line_total(k), 2)} "total": round(self.line_total(k), 2)}
for k, acc in LINE_ACCOUNTS for k, acc in LINE_ACCOUNTS
], ],
"receivable": {"key": RECEIVABLE_KEY, "gl_account": "Accounts Receivable:Amazon USA", "receivable": {"key": RECEIVABLE_KEY, "gl_account": gl("Accounts Receivable:Amazon USA"),
"values": [round(p.receivable, 2) for p in self.periods], "values": [round(p.receivable, 2) for p in self.periods],
"total": round(self.receivable_total, 2)}, "total": round(self.receivable_total, 2)},
# Balancing figure of the month-end ACCRUAL entry (Transfer excluded): Dr A/R by
# net revenue. What the Journal Entry tab and the Accounts Summary display.
"receivable_accrual": {
"key": "Receivable", "gl_account": gl("Accounts Receivable:Amazon USA"),
"values": [round(accrual_of(p), 2) for p in self.periods],
"total": round(sum(accrual_of(p) for p in self.periods), 2)},
"components": [ "components": [
{"key": k, "label": label, "group": group, {"key": k, "label": label, "group": group,
"values": [round(p.components[k], 2) for p in self.periods], "values": [round(p.components[k], 2) for p in self.periods],
@ -205,20 +250,28 @@ def _period_label(mind: date | None, maxd: date | None, fallback: str) -> str:
return fallback return fallback
def compute_journals(files: Iterable[str], saved_overrides: dict[str, str] | None = None, def compute_journals(files: Iterable[str | tuple[str, str | None]],
saved_overrides: dict[str, str] | None = None,
) -> dict[str, JournalResult]: ) -> dict[str, JournalResult]:
""" """
Re-read each file (= one period) and sum the journal lines, bucketed per marketplace. Re-read each file (= one period) and sum the journal lines, bucketed per marketplace.
A file containing several marketplaces (e.g. a combined Belgium+Germany report) is split A file containing several marketplaces (e.g. a combined Belgium+Germany report) is split
per region automatically via the report's own marketplace column. per region automatically via the report's own marketplace column.
`files` accepts the same (path, marketplace_override) tuples the settlement pipeline takes.
Passing plain paths here while the pipeline forced a marketplace label would bucket the same
rows under different marketplaces in the two passes, so the roll-forward and the settlement
receivable would disagree for a reason that has nothing to do with timing.
""" """
from .pipeline import iter_enriched_records from .pipeline import iter_enriched_records
import os import os
results: dict[str, JournalResult] = {} results: dict[str, JournalResult] = {}
for path in files: for entry in files:
path, override = entry if isinstance(entry, tuple) else (entry, None)
fname = os.path.basename(path) fname = os.path.basename(path)
periods: dict[str, JournalPeriod] = {} periods: dict[str, JournalPeriod] = {}
for rec in iter_enriched_records(path, saved_overrides=saved_overrides): for rec in iter_enriched_records(path, marketplace_override=override,
saved_overrides=saved_overrides):
region = rec["_marketplace"] region = rec["_marketplace"]
p = periods.get(region) p = periods.get(region)
if p is None: if p is None:
@ -248,7 +301,8 @@ def compute_journal(files: Iterable[str], saved_overrides: dict[str, str] | None
return max(multi.values(), key=lambda r: abs(r.line_total("Sales"))) return max(multi.values(), key=lambda r: abs(r.line_total("Sales")))
def journal_payload(files: Iterable[str], saved_overrides: dict[str, str] | None = None) -> dict: def journal_payload(files: Iterable[str | tuple[str, str | None]],
saved_overrides: dict[str, str] | None = None) -> dict:
"""JSON payload for storage: primary journal at the top level (backward-compatible), """JSON payload for storage: primary journal at the top level (backward-compatible),
plus every marketplace under `per_marketplace`.""" plus every marketplace under `per_marketplace`."""
multi = compute_journals(files, saved_overrides) multi = compute_journals(files, saved_overrides)

View File

@ -0,0 +1,78 @@
"""
Currency-safe aggregation.
Adding two different currencies is never a rounding problem it is a wrong number, and a
silent one. It happened here: the Reconciliation Control summed each marketplace's closing
balance in its own local currency (USD + EUR + GBP + PLN + SEK + CAD + AUD as one figure),
understating the Jan-2026 close by USD 444,658.44 against the correctly-converted total, and
that understated figure was what gated Finance sign-off.
`Total` makes the mistake impossible to repeat: every amount must be added with its currency,
and mixing codes raises instead of quietly producing a plausible number.
"""
from __future__ import annotations
USD = "USD"
class CurrencyMismatch(ValueError):
"""Raised when amounts in different currencies would be added together."""
class Total:
"""
An accumulator that refuses to mix currencies.
t = Total() # USD-converting accumulator
t.add_converted(230089.62, 1.185665)
t = Total(currency="EUR") # single-currency accumulator
t.add(100.0, "EUR") # ok
t.add(100.0, "GBP") # raises CurrencyMismatch
"""
__slots__ = ("currency", "_value", "_locked")
def __init__(self, currency: str | None = None):
self.currency = currency
self._value = 0.0
self._locked = currency is not None
def add(self, amount: float | None, currency: str | None) -> "Total":
"""Add an amount stated in `currency`. The first add fixes the accumulator's currency."""
cur = (currency or USD).strip().upper()
if self.currency is None:
self.currency = cur
elif cur != self.currency:
raise CurrencyMismatch(
f"refusing to add {cur} to a {self.currency} total "
f"(convert to a common currency first)"
)
self._value += float(amount or 0.0)
return self
def add_converted(self, amount: float | None, fx_rate: float | None) -> "Total":
"""Add a local amount converted to USD at `fx_rate`. Only valid on a USD total."""
if self.currency is None:
self.currency = USD
elif self.currency != USD:
raise CurrencyMismatch(
f"add_converted() produces USD but this total is {self.currency}"
)
self._value += float(amount or 0.0) * float(fx_rate if fx_rate is not None else 1.0)
return self
@property
def value(self) -> float:
return round(self._value, 2)
def __float__(self) -> float:
return self.value
def __repr__(self) -> str:
return f"Total({self.value:,.2f} {self.currency or '?'})"
def to_usd(amount: float | None, fx_rate: float | None) -> float:
"""Convert one local amount to USD."""
return round(float(amount or 0.0) * float(fx_rate if fx_rate is not None else 1.0), 2)

View File

@ -109,6 +109,7 @@ def process(
fx_rates: dict[str, float] | None = None, fx_rates: dict[str, float] | None = None,
currencies: dict[str, str] | None = None, currencies: dict[str, str] | None = None,
received_overrides: dict[tuple[str, str, str], bool] | None = None, received_overrides: dict[tuple[str, str, str], bool] | None = None,
manual_payouts: bool = False,
manual_adjustments: float = 0.0, manual_adjustments: float = 0.0,
expected_receivable: float | None = None, expected_receivable: float | None = None,
tolerance: float = DEFAULT_TOLERANCE, tolerance: float = DEFAULT_TOLERANCE,
@ -166,7 +167,8 @@ def process(
result.file_metas = metas result.file_metas = metas
emit("Calculating settlements", 0.88, seen, total_expected or seen) emit("Calculating settlements", 0.88, seen, total_expected or seen)
cls = classify(agg, month_end, clearing_lag_days, received_overrides) cls = classify(agg, month_end, clearing_lag_days, received_overrides,
manual_payouts=manual_payouts)
result.classification = cls result.classification = cls
emit("Creating receivable aging", 0.92, seen, total_expected or seen) emit("Creating receivable aging", 0.92, seen, total_expected or seen)

View File

@ -1,9 +1,18 @@
""" """
Reconciliation of the month-end close. Internal bucket accounting for the month-end close.
Internal identity (always holds and proves nothing was silently dropped):
uploaded_total = receivable_orders + paid_orders + transfers_total uploaded_total = receivable_orders + paid_orders + transfers_total
**This identity is not a control and must never be presented as one.** It is a tautology:
`uploaded_total` is accumulated from the same record stream that fills the three buckets, and
every record lands in exactly one of them, so it is one sum written twice. It cannot detect a
misclassified settlement, a wrong FX rate, an unmapped column, or an entire file that failed
to parse (a file yielding no rows contributes zero to both sides). It reported "Reconciled" on
the Jan-2026 close while the group receivable was understated by USD 444,658.44.
It is kept as a cheap assert that the bucketing code itself is self-consistent. The controls
that can actually fail live in `core/controls.py`, and they are what the dashboard reports.
Closing receivable: Closing receivable:
final_receivable_usd = Σ_marketplace ROUND(reserve + additional_sales) x fx final_receivable_usd = Σ_marketplace ROUND(reserve + additional_sales) x fx
+ manual_adjustments + manual_adjustments

View File

@ -25,6 +25,11 @@ from datetime import date, timedelta
from typing import Iterable from typing import Iterable
TRANSFER_TYPE = "Transfer" TRANSFER_TYPE = "Transfer"
# Bucket label for rows carrying no account type (Amazon leaves it blank on transfers in every
# marketplace except the USA). Persisted rows must be labelled with this exact string, or the
# post-classification UPDATE — which matches on (settlement, marketplace, account_type) — silently
# misses every payout row and leaves its status NULL.
UNSPECIFIED_ACCOUNT = "(unspecified)"
# Real order account types that participate in the receivable SUMIFS. "All Orders" is the # Real order account types that participate in the receivable SUMIFS. "All Orders" is the
# synthetic single stream for marketplaces whose report has no `account type` column # synthetic single stream for marketplaces whose report has no `account type` column
# (every marketplace except USA). # (every marketplace except USA).
@ -96,6 +101,17 @@ class AggregationResult:
undated_samples: list[str] = field(default_factory=list) undated_samples: list[str] = field(default_factory=list)
potential_storage_count: int = 0 potential_storage_count: int = 0
potential_storage_samples: list[str] = field(default_factory=list) potential_storage_samples: list[str] = field(default_factory=list)
# --- Control C3 (bucket completeness) ------------------------------------
# Money-carrying order rows the engine cannot place. Both are silently EXCLUDED from the
# receivable downstream (an unrecognized account type fails the SUMIFS filter; a
# non-numeric settlement id sorts to -1 and is classified "paid"), so they must be
# counted here and block the close rather than quietly reduce the number.
unclassified_acct_count: int = 0
unclassified_acct_total: float = 0.0
unclassified_acct_samples: list[str] = field(default_factory=list)
unclassified_sid_count: int = 0
unclassified_sid_total: float = 0.0
unclassified_sid_samples: list[str] = field(default_factory=list)
def _dupe_key(rec: dict) -> int: def _dupe_key(rec: dict) -> int:
@ -166,8 +182,27 @@ def aggregate(records: Iterable[dict], default_marketplace: str = "USA",
if acct_raw: if acct_raw:
res.account_types_seen.add(acct_raw) res.account_types_seen.add(acct_raw)
# Control C3: an order row carrying money must be placeable in a receivable bucket.
if txn_type != TRANSFER_TYPE and total:
if acct_raw.lower() not in RECEIVABLE_ACCOUNT_TYPES:
res.unclassified_acct_count += 1
res.unclassified_acct_total += total
if len(res.unclassified_acct_samples) < 10:
res.unclassified_acct_samples.append(
f"{rec.get('_source_file')} row {rec.get('_source_row')} "
f"[{marketplace}] account type={acct_raw or '(blank)'!r} "
f"total={total:,.2f}")
if _as_int(sid) < 0:
res.unclassified_sid_count += 1
res.unclassified_sid_total += total
if len(res.unclassified_sid_samples) < 10:
res.unclassified_sid_samples.append(
f"{rec.get('_source_file')} row {rec.get('_source_row')} "
f"[{marketplace}] settlement id={sid or '(blank)'!r} "
f"total={total:,.2f}")
# Group transfers under the account type they carry (Amazon tags transfers with one). # Group transfers under the account type they carry (Amazon tags transfers with one).
acct_key = acct_raw or _UNSPEC acct_key = account_bucket(acct_raw)
key = (marketplace, acct_key, sid) key = (marketplace, acct_key, sid)
st = settlements.get(key) st = settlements.get(key)
if st is None: if st is None:
@ -194,7 +229,13 @@ def aggregate(records: Iterable[dict], default_marketplace: str = "USA",
return res return res
_UNSPEC = "(unspecified)" _UNSPEC = UNSPECIFIED_ACCOUNT
def account_bucket(account_type: str | None) -> str:
"""The bucket label a row is aggregated under — the single definition of that mapping,
shared by the aggregator and by transaction persistence so the two cannot disagree."""
return (account_type or "").strip() or UNSPECIFIED_ACCOUNT
def _marketplace_of(rec: dict, default: str) -> str: def _marketplace_of(rec: dict, default: str) -> str:
@ -226,11 +267,21 @@ def classify(
month_end: date, month_end: date,
clearing_lag_days: int = 2, clearing_lag_days: int = 2,
received_overrides: dict[tuple[str, str, str], bool] | None = None, received_overrides: dict[tuple[str, str, str], bool] | None = None,
manual_payouts: bool = False,
) -> Classification: ) -> Classification:
""" """
Mark each transfer received/in-transit (auto clearing-lag, with optional overrides Mark each transfer received/in-transit, derive the paid boundary, and classify every
keyed by (marketplace, account_type, settlement_id)), derive the paid boundary, and settlement paid/receivable.
classify every settlement paid/receivable.
Received status, in priority order:
1. `received_overrides` keyed (marketplace, account_type, settlement_id). Built from
Finance's bank-receipt entries: True iff the money reached the BANK by month-end.
Amazon's Transfer date is only when the payout was initiated; the bank credit lands
3-5 working days later, so a receipt is the ground truth and always wins.
2. No override, manual_payouts=False (auto): the clearing-lag heuristic
received iff transfer date month_end clearing_lag_days.
3. No override, manual_payouts=True: NOT received. Finance records every bank credit
by hand, so a payout without a receipt has, by definition, not been received.
Multi-marketplace mechanics (verified against the Jan-2026 workbook): Multi-marketplace mechanics (verified against the Jan-2026 workbook):
* A settlement belongs to the marketplace owning the majority of its non-transfer * A settlement belongs to the marketplace owning the majority of its non-transfer
@ -269,7 +320,8 @@ def classify(
for t in agg.transfers: for t in agg.transfers:
ok = overrides.get((t.marketplace, t.account_type, t.settlement_id)) ok = overrides.get((t.marketplace, t.account_type, t.settlement_id))
if ok is None: if ok is None:
ok = (t.txn_date is not None and t.txn_date <= cutoff) ok = False if manual_payouts else (
t.txn_date is not None and t.txn_date <= cutoff)
t.received = bool(ok) t.received = bool(ok)
if t.received: if t.received:
owner = cls.settlement_owner.get(t.settlement_id, t.marketplace) owner = cls.settlement_owner.get(t.settlement_id, t.marketplace)

View File

@ -1,7 +1,8 @@
""" """
Summary Excel the compact month-end pack Finance reviews and circulates. Summary Excel the compact month-end pack Finance reviews and circulates.
Sheets: Finance Summary · AR Ledger · Reconciliation Control · Category Totals · Audit Trail Sheets: Finance Summary · AR Ledger · Reconciliation Control · Category Totals ·
Month-End Controls · Audit Trail
(The heavy transaction-level evidence lives in the Full workbook.) (The heavy transaction-level evidence lives in the Full workbook.)
""" """
from __future__ import annotations from __future__ import annotations
@ -50,7 +51,8 @@ def _money(ws, row: int, col: int, value, fmt=FMT_ACCT2, bold=False, fill=None):
def export_summary_workbook(output_path: str, summary: dict, control: dict, def export_summary_workbook(output_path: str, summary: dict, control: dict,
journal: dict, meta: dict | None = None) -> str: journal: dict, meta: dict | None = None,
controls: dict | None = None) -> str:
meta = meta or {} meta = meta or {}
wb = Workbook() wb = Workbook()
@ -212,6 +214,34 @@ def export_summary_workbook(output_path: str, summary: dict, control: dict,
wt.cell(row=r, column=3 + len(rec["values"]), value=rec["gl_account"]).border = BORDER wt.cell(row=r, column=3 + len(rec["values"]), value=rec["gl_account"]).border = BORDER
wt.freeze_panes = "A2" wt.freeze_panes = "A2"
# ---------------- Month-End Controls ----------------
# Ships with the pack so the workbook carries its own evidence of correctness: an auditor
# can see which checks ran, what each compared against, and the result — without the app.
wc = wb.create_sheet("Month-End Controls")
wc.column_dimensions["A"].width = 6
wc.column_dimensions["B"].width = 26
wc.column_dimensions["C"].width = 12
wc.column_dimensions["D"].width = 90
wc["A1"] = "Month-end controls"
wc["A1"].font = TITLE
wc["A2"] = ("Each control compares the engine's output against something the engine did not "
"produce. An error-severity failure blocks the closing and withholds the figure.")
wc["A2"].font = Font(color="5B6070")
_hdr(wc, 4, ["#", "Control", "Result", "Detail"])
r = 5
for c in (controls or {}).get("controls", []):
wc.cell(row=r, column=1, value=c.get("key", "")).border = BORDER
wc.cell(row=r, column=2, value=c.get("label", "")).border = BORDER
res = wc.cell(row=r, column=3, value=c.get("status", "").upper())
res.border, res.font = BORDER, BOLD
wc.cell(row=r, column=4, value=c.get("detail", "")).border = BORDER
r += 1
for e in c.get("evidence", []):
wc.cell(row=r, column=4, value=f" {e}").font = Font(color="5B6070", size=9)
r += 1
if r == 5:
wc.cell(row=r, column=2, value="No controls recorded for this closing.")
# ---------------- Audit Trail ---------------- # ---------------- Audit Trail ----------------
wa = wb.create_sheet("Audit Trail") wa = wb.create_sheet("Audit Trail")
wa.column_dimensions["A"].width = 30 wa.column_dimensions["A"].width = 30

View File

@ -67,12 +67,31 @@ class FileMeta:
marketplace: str | None = None marketplace: str | None = None
unmapped_headers: dict[str, str] = field(default_factory=dict) unmapped_headers: dict[str, str] = field(default_factory=dict)
missing_required: list[str] = field(default_factory=list) missing_required: list[str] = field(default_factory=list)
# canonical field -> the columns that both claimed it (only the first is used)
duplicate_fields: dict[str, list] = field(default_factory=dict)
# Finance-added translation/helper header rows found below the real header and skipped. # Finance-added translation/helper header rows found below the real header and skipped.
helper_rows_skipped: int = 0 helper_rows_skipped: int = 0
# column-letter -> Σ of numeric values seen in columns with NO mapped field. # column-letter -> Σ of numeric values seen in columns with NO mapped field.
# Non-zero sums are surfaced as errors: no amount is ever silently excluded. # Non-zero sums are surfaced as errors: no amount is ever silently excluded.
unmapped_amount_sums: dict[str, float] = field(default_factory=dict) unmapped_amount_sums: dict[str, float] = field(default_factory=dict)
# --- Control C1 (source row count) ---------------------------------------
# The worksheet's OWN declared extent, read from <dimension>/the last <row> — i.e. from the
# file, not from our record stream. Everything else in this dataclass is downstream of
# parsing, so it cannot detect parsing that silently stopped early. This can.
sheet_last_row: int = 0
blank_rows_skipped: int = 0
@property
def expected_data_rows(self) -> int:
"""Data rows the worksheet claims to hold, below the detected header row."""
return max(0, self.sheet_last_row - self.header_row)
@property
def rows_accounted_for(self) -> int:
"""Rows we can explain: imported + deliberately skipped."""
return self.imported_rows + self.helper_rows_skipped + self.blank_rows_skipped
class TransactionReader: class TransactionReader:
# A sheet's header row must resolve at least these to be considered the data sheet. # A sheet's header row must resolve at least these to be considered the data sheet.
@ -233,8 +252,27 @@ class TransactionReader:
self.file_meta.header_row = hdr_row self.file_meta.header_row = hdr_row
self.file_meta.unmapped_headers = mapping.unmapped self.file_meta.unmapped_headers = mapping.unmapped
self.file_meta.missing_required = mapping.missing_required self.file_meta.missing_required = mapping.missing_required
self.file_meta.duplicate_fields = mapping.duplicate_fields
self.file_meta.sheet_last_row = self._declared_last_row(part)
return mapping return mapping
def _declared_last_row(self, part: str) -> int:
"""
Last row the worksheet itself declares, from <dimension ref="A1:AD780000"/>.
Read straight from the file header (a few KB) so it is independent of our row
streaming that independence is what makes control C1 able to fail.
"""
assert self._zip is not None
try:
with self._zip.open(part) as fh:
head = fh.read(8192).decode("utf-8", "replace")
m = re.search(r'<dimension\s+ref="[A-Z]+\d+:([A-Z]+)(\d+)"', head)
if m:
return int(m.group(2))
except (KeyError, OSError, ValueError):
pass
return 0
# -- records ------------------------------------------------------------- # -- records -------------------------------------------------------------
def iter_records(self, only_fields: set[str] | None = None) -> Iterator[dict]: def iter_records(self, only_fields: set[str] | None = None) -> Iterator[dict]:
""" """
@ -275,9 +313,13 @@ class TransactionReader:
if want is not None and fld not in want: if want is not None and fld not in want:
continue continue
rec[fld] = _convert(fld, val) rec[fld] = _convert(fld, val)
if rec[fld] not in (None, ""): # Shared row-emptiness rule (must match CalamineReader exactly): a row counts
# when any mapped column holds a NON-EMPTY SOURCE cell — never the converted
# value, which turns empty amount cells into 0.0 and makes every row qualify.
if val not in (None, ""):
has_value = True has_value = True
if not has_value: if not has_value:
self.file_meta.blank_rows_skipped += 1
continue continue
# track meta cheaply # track meta cheaply
d = None d = None

View File

@ -94,6 +94,25 @@ def _migrate() -> None:
("eta_seconds", "INTEGER DEFAULT 0"), ("eta_seconds", "INTEGER DEFAULT 0"),
("opening_mode", "VARCHAR(255) DEFAULT 'zero'"), ("opening_mode", "VARCHAR(255) DEFAULT 'zero'"),
("opening_source_session_id", "INTEGER"), ("opening_source_session_id", "INTEGER"),
("blocked_reason", "TEXT DEFAULT ''"),
("payout_mode", "VARCHAR DEFAULT 'auto'"),
("needs_reprocess", "BOOLEAN DEFAULT 0"),
],
"session_files": [
("sheet_last_row", "INTEGER DEFAULT 0"),
("blank_rows_skipped", "INTEGER DEFAULT 0"),
("helper_rows_skipped", "INTEGER DEFAULT 0"),
],
"fx_rates": [
("confirmed_by", "VARCHAR DEFAULT ''"),
("confirmed_at", "DATETIME"),
("confirmed_month", "VARCHAR DEFAULT ''"),
],
"journal_entries": [
("reviewed_by", "VARCHAR DEFAULT ''"),
("reviewed_at", "DATETIME"),
("approved_by", "VARCHAR DEFAULT ''"),
("approved_at", "DATETIME"),
], ],
"reconciliation": [ "reconciliation": [
("received_payouts", "FLOAT DEFAULT 0"), ("received_payouts", "FLOAT DEFAULT 0"),

View File

@ -30,7 +30,18 @@ class Session(Base):
# How the opening AR balance is established: zero (default) | carry_forward | manual # How the opening AR balance is established: zero (default) | carry_forward | manual
opening_mode = Column(String(32), default="zero") opening_mode = Column(String(32), default="zero")
opening_source_session_id = Column(Integer) opening_source_session_id = Column(Integer)
status = Column(String(32), default="draft") # draft|processing|processed|error # draft|processing|processed|blocked|completed|error
# "blocked" = processed, but a month-end control failed, so no receivable figure is
# released to the dashboard or to an export until it is resolved.
status = Column(String(32), default="draft")
blocked_reason = Column(Text, default="")
# How payouts count as received:
# auto — bank-receipt date when entered, clearing-lag heuristic otherwise (default)
# manual — ONLY payouts with a bank-receipt date ≤ month-end count; no heuristic
payout_mode = Column(String(32), default="auto")
# Receipts or payout mode changed after the last processing run — the classification on
# screen no longer reflects them until the closing is re-processed.
needs_reprocess = Column(Boolean, default=False)
progress_stage = Column(String(255), default="") progress_stage = Column(String(255), default="")
progress_pct = Column(Float, default=0.0) progress_pct = Column(Float, default=0.0)
progress_rows_done = Column(Integer, default=0) progress_rows_done = Column(Integer, default=0)
@ -64,6 +75,10 @@ class SessionFile(Base):
marketplace = Column(String(64)) marketplace = Column(String(64))
status = Column(String(32), default="uploaded") # uploaded|parsed|invalid status = Column(String(32), default="uploaded") # uploaded|parsed|invalid
message = Column(Text, default="") message = Column(Text, default="")
# Control C1: the worksheet's own declared extent vs what we actually consumed.
sheet_last_row = Column(Integer, default=0)
blank_rows_skipped = Column(Integer, default=0)
helper_rows_skipped = Column(Integer, default=0)
session = relationship("Session", back_populates="files") session = relationship("Session", back_populates="files")
@ -147,9 +162,55 @@ class FxRate(Base):
rate = Column(Float, default=1.0) rate = Column(Float, default=1.0)
source = Column(String(64), default="manual") source = Column(String(64), default="manual")
rate_date = Column(Date) rate_date = Column(Date)
# Control C5: a seeded default is a SUGGESTION, not a rate. Until someone confirms it for
# this reporting month the closing is blocked — otherwise a July close silently values EUR
# at the hardcoded January rate.
confirmed_by = Column(String(255), default="")
confirmed_at = Column(DateTime)
confirmed_month = Column(String(32), default="") # reporting month the confirmation is for
session = relationship("Session", back_populates="fx_rates") session = relationship("Session", back_populates="fx_rates")
class PayoutReceipt(Base):
"""
When a payout actually reached the BANK entered by Finance per payout.
Amazon's Transfer row carries the date Amazon *initiated* the payout; the money lands
3-5 working days later. A receipt overrides the clearing-lag heuristic for its payout:
received iff bank_date month-end. Keyed exactly like the engine's transfer overrides,
(marketplace, account_type bucket, settlement_id).
"""
__tablename__ = "payout_receipts"
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
marketplace = Column(String(64), nullable=False)
account_type = Column(String(64), nullable=False) # bucket label, e.g. "(unspecified)"
settlement_id = Column(String(255), nullable=False)
bank_date = Column(Date, nullable=False)
bank_amount = Column(Float) # optional; None = same as Amazon amount
note = Column(String(512), default="")
entered_by = Column(String(255), default="")
updated_at = Column(DateTime, default=_now, onupdate=_now)
__table_args__ = (
Index("ix_payout_receipts_key", "session_id", "marketplace",
"account_type", "settlement_id", unique=True),
)
class ControlResult(Base):
"""Outcome of one month-end control (see core/controls.py). One row per control per close."""
__tablename__ = "control_results"
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
key = Column(String(32)) # "C1".."C6"
label = Column(String(255))
status = Column(String(32)) # pass|fail|not_applicable
severity = Column(String(32), default="error") # error|warning|info
detail = Column(Text, default="")
evidence = Column(Text, default="") # JSON list of strings
checked_at = Column(DateTime, default=_now)
class Reserve(Base): class Reserve(Base):
__tablename__ = "reserves" __tablename__ = "reserves"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
@ -261,6 +322,14 @@ class JournalEntry(Base):
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=True)
entry_no = Column(String(128), default="") entry_no = Column(String(128), default="")
data = Column(Text, default="") # JSON: periods + lines + receivable data = Column(Text, default="") # JSON: periods + lines + receivable
# Two-step sign-off. Approval is what publishes this month's journal to the Accounts
# Summary. Re-processing rebuilds the journal row, so both clear automatically whenever
# the numbers change — a sign-off only ever attests to figures the signer actually saw
# (entry_no is carried over; see jobs.run_processing).
reviewed_by = Column(String(255), default="")
reviewed_at = Column(DateTime)
approved_by = Column(String(255), default="")
approved_at = Column(DateTime)
class ExportRecord(Base): class ExportRecord(Base):

View File

@ -0,0 +1,179 @@
"""
Run the month-end controls for a session, persist the results, and block the close when a
control fails with error severity.
Runs at the end of processing (after the journal exists) and again on demand, so that
changing an opening balance, an FX rate or a reserve re-evaluates the controls rather than
leaving a stale green light.
"""
from __future__ import annotations
import json
from sqlalchemy.orm import Session as OrmSession
from ..core import controls
from ..core.controls import ControlResult
from ..db import models
def _per_market_rows(db: OrmSession, session_id: int) -> tuple[list[dict], float | None]:
"""Per-marketplace movement rows + the FX-converted group closing (All Markets total)."""
from ..api.routes.analytics import all_markets
try:
data = all_markets(session_id, db)
except Exception: # noqa: BLE001 — a control run must never break the close
return [], None
if not data.get("available"):
return [], None
return data.get("markets", []), data.get("total", {}).get("closing_usd")
def _control_total(db: OrmSession, session_id: int) -> float | None:
from ..api.routes.control import _dashboard_metrics
try:
m = _dashboard_metrics(db, session_id)
except Exception: # noqa: BLE001
return None
return m.get("closing_receivable") if m else None
def evaluate(db: OrmSession, session_id: int, result=None) -> list[ControlResult]:
"""
Evaluate every control. `result` is the in-memory ProcessResult when called straight
after processing; on a re-run the file/aggregate controls fall back to what was persisted.
"""
session = db.get(models.Session, session_id)
out: list[ControlResult] = []
# C1 — source row count (engine metas when fresh, persisted file rows otherwise)
metas = list(result.file_metas) if result and result.file_metas else _stored_metas(db, session_id)
out.append(controls.c1_source_row_count(metas))
# C2 — column completeness (journal GL lines vs the source `total` column)
j = db.query(models.JournalEntry).filter(
models.JournalEntry.session_id == session_id).first()
journal = json.loads(j.data) if j and j.data else {}
recon = db.query(models.ReconciliationRow).filter(
models.ReconciliationRow.session_id == session_id).first()
uploaded = recon.uploaded_total if recon else 0.0
out.append(controls.c2_column_completeness(journal, uploaded))
# C3 — bucket completeness (only measurable from the aggregation pass)
if result and result.aggregation:
out.append(controls.c3_bucket_completeness(result.aggregation))
else:
out.append(_prior(db, session_id, "C3", "Bucket completeness"))
# C4 / C6 — cross-method and cross-surface agreement
rows, all_markets_usd = _per_market_rows(db, session_id)
openings_all_zero = not db.query(models.OpeningBalance).filter(
models.OpeningBalance.session_id == session_id,
models.OpeningBalance.amount != 0).first()
out.append(controls.c4_dual_method(rows, openings_all_zero=openings_all_zero))
out.append(controls.c6_currency_integrity(_control_total(db, session_id), all_markets_usd))
# C5 — FX confirmed for this reporting month
fx_rows = db.query(models.FxRate).filter(models.FxRate.session_id == session_id).all()
markets = [r["marketplace"] for r in rows] or [f.marketplace for f in fx_rows if f.marketplace]
out.append(controls.c5_fx_confirmed(fx_rows, (session.reporting_month if session else "") or "",
markets))
return out
class _Meta:
"""Minimal stand-in for FileMeta when re-running controls without re-parsing."""
def __init__(self, f: models.SessionFile):
self.filename = f.filename
self.header_row = 0
self.sheet_last_row = f.sheet_last_row or 0
self.imported_rows = f.imported_rows or 0
self.helper_rows_skipped = f.helper_rows_skipped or 0
self.blank_rows_skipped = f.blank_rows_skipped or 0
@property
def expected_data_rows(self) -> int:
return max(0, self.sheet_last_row - self.header_row)
@property
def rows_accounted_for(self) -> int:
return self.imported_rows + self.helper_rows_skipped + self.blank_rows_skipped
def _stored_metas(db: OrmSession, session_id: int) -> list:
rows = db.query(models.SessionFile).filter(
models.SessionFile.session_id == session_id).all()
# header_row isn't persisted; reuse the stored expected count only when it was recorded.
metas = []
for f in rows:
m = _Meta(f)
if m.sheet_last_row:
# Reconstruct the header offset from what was imported, so a stale re-run cannot
# invent a mismatch; a genuine mismatch still shows up on the next full process.
m.header_row = max(0, m.sheet_last_row - m.rows_accounted_for)
metas.append(m)
return metas
def _prior(db: OrmSession, session_id: int, key: str, label: str) -> ControlResult:
"""Carry a previous run's verdict forward when it cannot be recomputed without re-parsing."""
row = db.query(models.ControlResult).filter(
models.ControlResult.session_id == session_id,
models.ControlResult.key == key).first()
if row is None:
return ControlResult(key=key, label=label, status=controls.NA, severity="info",
detail="Not evaluated on this run (requires re-processing).")
try:
evidence = json.loads(row.evidence) if row.evidence else []
except ValueError:
evidence = []
return ControlResult(key=row.key, label=row.label, status=row.status,
severity=row.severity, detail=row.detail, evidence=evidence)
def run_and_persist(db: OrmSession, session_id: int, result=None) -> dict:
"""Evaluate, store, and apply the block. Returns a JSON-ready summary."""
results = evaluate(db, session_id, result)
db.query(models.ControlResult).filter(
models.ControlResult.session_id == session_id).delete(synchronize_session=False)
for r in results:
db.add(models.ControlResult(
session_id=session_id, key=r.key, label=r.label, status=r.status,
severity=r.severity, detail=r.detail, evidence=json.dumps(r.evidence)))
reason = controls.blocking_summary(results)
session = db.get(models.Session, session_id)
if session is not None:
session.blocked_reason = reason
# Never downgrade a terminal state; only flip between processed and blocked.
if session.status in ("processed", "blocked", "completed"):
session.status = "blocked" if reason else (
"completed" if session.status == "completed" and not reason else "processed")
db.commit()
return payload(db, session_id)
def payload(db: OrmSession, session_id: int) -> dict:
rows = db.query(models.ControlResult).filter(
models.ControlResult.session_id == session_id).order_by(models.ControlResult.key).all()
session = db.get(models.Session, session_id)
out = []
for r in rows:
try:
evidence = json.loads(r.evidence) if r.evidence else []
except ValueError:
evidence = []
out.append({"key": r.key, "label": r.label, "status": r.status,
"severity": r.severity, "detail": r.detail, "evidence": evidence,
"checked_at": r.checked_at.isoformat() if r.checked_at else None})
return {
"available": bool(out),
"controls": out,
"blocked": bool(session and session.blocked_reason),
"blocked_reason": (session.blocked_reason if session else "") or "",
"passed": sum(1 for r in out if r["status"] == "pass"),
"failed": sum(1 for r in out if r["status"] == "fail"),
"total": len(out),
}

View File

@ -43,6 +43,20 @@ def run_processing(session_id: int) -> None:
currencies = {**CURRENCY_BY_REGION, **{r.marketplace: r.currency for r in fx_rows}} currencies = {**CURRENCY_BY_REGION, **{r.marketplace: r.currency for r in fx_rows}}
mapping_rules = load_mapping_rules(db) mapping_rules = load_mapping_rules(db)
# Bank receipts: Finance's record of when each payout actually reached the bank.
# A receipt overrides the clearing-lag heuristic for its payout — received iff the
# BANK date is on/before month-end. In manual mode the heuristic is off entirely
# and a payout without a receipt is not received.
receipts = db.query(models.PayoutReceipt).filter(
models.PayoutReceipt.session_id == session_id).all()
received_overrides = {
(r.marketplace, r.account_type, r.settlement_id):
bool(r.bank_date and session.month_end_date
and r.bank_date <= session.month_end_date)
for r in receipts
}
manual_payouts = (session.payout_mode or "auto") == "manual"
session.status = "processing" session.status = "processing"
session.error = "" session.error = ""
session.progress_stage = "Reading workbook" session.progress_stage = "Reading workbook"
@ -69,6 +83,7 @@ def run_processing(session_id: int) -> None:
month_end=session.month_end_date, month_end=session.month_end_date,
clearing_lag_days=session.clearing_lag_days or 2, clearing_lag_days=session.clearing_lag_days or 2,
reserves=reserves, fx_rates=fx_rates, currencies=currencies, reserves=reserves, fx_rates=fx_rates, currencies=currencies,
received_overrides=received_overrides, manual_payouts=manual_payouts,
manual_adjustments=session.manual_adjustment or 0.0, manual_adjustments=session.manual_adjustment or 0.0,
tolerance=session.rounding_tolerance or 0.01, tolerance=session.rounding_tolerance or 0.01,
saved_column_overrides=mapping_rules, saved_column_overrides=mapping_rules,
@ -97,17 +112,41 @@ def run_processing(session_id: int) -> None:
import json import json
from ..core.journal import journal_payload from ..core.journal import journal_payload
payload = journal_payload(paths, mapping_rules) payload = journal_payload(paths, mapping_rules)
old = db.query(models.JournalEntry).filter(
models.JournalEntry.session_id == session_id).first()
# The entry number survives a re-process; review/approval deliberately do NOT —
# the numbers just changed, so any sign-off no longer attests to what's stored.
entry_no = old.entry_no if old else ""
# Bulk delete executes immediately — an ORM delete+add pair can flush the INSERT
# before the DELETE and trip the unique(session_id) constraint, silently keeping
# the OLD journal (and its stale sign-off) via the except below.
db.query(models.JournalEntry).filter( db.query(models.JournalEntry).filter(
models.JournalEntry.session_id == session_id).delete() models.JournalEntry.session_id == session_id).delete(synchronize_session=False)
db.add(models.JournalEntry(session_id=session_id, data=json.dumps(payload))) db.add(models.JournalEntry(session_id=session_id, data=json.dumps(payload),
entry_no=entry_no))
db.commit() db.commit()
except Exception: # noqa: BLE001 — journal is supplementary; never fail the close over it except Exception: # noqa: BLE001 — journal is supplementary; never fail the close over it
db.rollback() db.rollback()
# Bank-receipt sanity: a receipt whose amount differs from Amazon's payout, or whose
# key matches no payout in the files, is surfaced — never silently absorbed.
try:
_receipt_exceptions(db, session_id, receipts, result)
except Exception: # noqa: BLE001 — advisory only; never fail the close over it
db.rollback()
session.status = "processed" session.status = "processed"
session.progress_stage = "Done" session.progress_stage = "Done"
session.progress_pct = 1.0 session.progress_pct = 1.0
session.needs_reprocess = False # this run reflects the receipts as of now
db.commit() db.commit()
# Month-end controls run LAST, over everything that was just persisted, and set
# status to "blocked" if any of them fails with error severity. A close that cannot
# be trusted must not publish a receivable figure.
progress("Running month-end controls", 0.99)
from .controls_run import run_and_persist
run_and_persist(db, session_id, result)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
db.rollback() db.rollback()
session = db.get(models.Session, session_id) session = db.get(models.Session, session_id)
@ -119,6 +158,35 @@ def run_processing(session_id: int) -> None:
db.close() db.close()
def _receipt_exceptions(db, session_id: int, receipts, result) -> None:
"""Warn on bank receipts that disagree with the files (wrong amount / no such payout)."""
agg = result.aggregation
if agg is None:
return
# Amazon payout per receipt key = the bucket's transfer total.
by_key = {(m, a, s): st.transfer_total for (m, a, s), st in agg.settlements.items()
if st.transfer_count}
for r in receipts:
key = (r.marketplace, r.account_type, r.settlement_id)
amazon = by_key.get(key)
if amazon is None:
db.add(models.Exception_(
session_id=session_id, category="unmatched_bank_receipt", severity="warning",
detail=(f"{r.marketplace}: a bank receipt dated {r.bank_date} is entered for "
f"settlement {r.settlement_id}, but no payout with that settlement "
f"exists in the uploaded files — check the settlement id"),
source=r.marketplace))
elif r.bank_amount is not None and abs(abs(r.bank_amount) - abs(amazon)) > 0.01:
db.add(models.Exception_(
session_id=session_id, category="bank_amount_variance", severity="warning",
detail=(f"{r.marketplace} settlement {r.settlement_id}: bank received "
f"{r.bank_amount:,.2f} on {r.bank_date} but Amazon's payout is "
f"{amazon:,.2f} — difference {abs(r.bank_amount) - abs(amazon):+,.2f} "
f"(bank fee or partial payment; the ledger uses Amazon's amount)"),
source=r.marketplace))
db.commit()
def _fail(db, session, message: str) -> None: def _fail(db, session, message: str) -> None:
session.status = "error" session.status = "error"
session.error = message session.error = message
@ -179,7 +247,9 @@ def run_summary_export(session_id: int) -> None:
EXPORT_DIR.mkdir(parents=True, exist_ok=True) EXPORT_DIR.mkdir(parents=True, exist_ok=True)
month = session.reporting_month or "output" month = session.reporting_month or "output"
out_path = str(EXPORT_DIR / f"AR_Summary_{month}_session{session_id}.xlsx") out_path = str(EXPORT_DIR / f"AR_Summary_{month}_session{session_id}.xlsx")
export_summary_workbook(out_path, summary, control, journal, meta) from .controls_run import payload as controls_payload
export_summary_workbook(out_path, summary, control, journal, meta,
controls=controls_payload(db, session_id))
_finalize_export(db, session_id, out_path, "summary") _finalize_export(db, session_id, out_path, "summary")
session.status = "processed" session.status = "processed"
@ -239,9 +309,21 @@ def run_export(session_id: int) -> None:
session.eta_seconds = int(elapsed / p - elapsed) if p > 0.03 else 0 session.eta_seconds = int(elapsed / p - elapsed) if p > 0.03 else 0
db.commit() db.commit()
# Same bank-receipt overrides as run_processing — the exported workbook must show
# the identical paid/receivable split the dashboard shows.
receipts = db.query(models.PayoutReceipt).filter(
models.PayoutReceipt.session_id == session_id).all()
received_overrides = {
(r.marketplace, r.account_type, r.settlement_id):
bool(r.bank_date and session.month_end_date
and r.bank_date <= session.month_end_date)
for r in receipts
}
result = process(paths, month_end=session.month_end_date, result = process(paths, month_end=session.month_end_date,
clearing_lag_days=session.clearing_lag_days or 2, clearing_lag_days=session.clearing_lag_days or 2,
reserves=reserves, fx_rates=fx_rates, currencies=currencies, reserves=reserves, fx_rates=fx_rates, currencies=currencies,
received_overrides=received_overrides,
manual_payouts=(session.payout_mode or "auto") == "manual",
manual_adjustments=session.manual_adjustment or 0.0, manual_adjustments=session.manual_adjustment or 0.0,
tolerance=session.rounding_tolerance or 0.01, tolerance=session.rounding_tolerance or 0.01,
saved_column_overrides=mapping_rules, progress=progress) saved_column_overrides=mapping_rules, progress=progress)

View File

@ -7,7 +7,7 @@ from typing import Any
from sqlalchemy.orm import Session as OrmSession from sqlalchemy.orm import Session as OrmSession
from ..core.pipeline import ProcessResult from ..core.pipeline import ProcessResult
from ..core.settlements import RECEIVABLE_ACCOUNT_TYPES from ..core.settlements import RECEIVABLE_ACCOUNT_TYPES, account_bucket
from ..db import models from ..db import models
from ..db.database import ENGINE from ..db.database import ENGINE
@ -40,7 +40,11 @@ class TransactionSink:
rec.get("_source_file"), rec.get("_source_sheet"), rec.get("_source_row"), rec.get("_source_file"), rec.get("_source_sheet"), rec.get("_source_row"),
rec.get("_marketplace"), rec.get("settlement_id"), rec.get("order_id"), rec.get("_marketplace"), rec.get("settlement_id"), rec.get("order_id"),
rec.get("sku"), rec.get("txn_type"), rec.get("_type_en") or rec.get("txn_type"), rec.get("sku"), rec.get("txn_type"), rec.get("_type_en") or rec.get("txn_type"),
rec.get("account_type"), # Persist the BUCKET label, not the raw cell: apply_classification() matches on this
# column, and a blank/whitespace value never matched the "(unspecified)" bucket that
# every non-USA payout row aggregates under — leaving 25 rows worth -4,782,085.25
# with a NULL settlement_status on the Jan-2026 close.
account_bucket(rec.get("account_type")),
d.isoformat() if d else None, float(rec.get("total") or 0.0), d.isoformat() if d else None, float(rec.get("total") or 0.0),
rec.get("currency") or "USD", 1 if rec.get("_storage") else 0, rec.get("currency") or "USD", 1 if rec.get("_storage") else 0,
)) ))
@ -93,7 +97,8 @@ class TransactionSink:
def clear_session_results(db: OrmSession, session_id: int) -> None: def clear_session_results(db: OrmSession, session_id: int) -> None:
for model in (models.Settlement, models.Exception_, models.ReceivableResultRow, for model in (models.Settlement, models.Exception_, models.ReceivableResultRow,
models.ReconciliationRow, models.Transaction, models.MarketPayout): models.ReconciliationRow, models.Transaction, models.MarketPayout,
models.ControlResult):
db.query(model).filter(model.session_id == session_id).delete() db.query(model).filter(model.session_id == session_id).delete()
db.commit() db.commit()
@ -105,7 +110,7 @@ _CHILD_MODELS = (
models.ReconciliationRow, models.MarketPayout, models.Exception_, models.ReconciliationRow, models.MarketPayout, models.Exception_,
models.OpeningBalance, models.FxRate, models.FxRateDaily, models.Reserve, models.OpeningBalance, models.FxRate, models.FxRateDaily, models.Reserve,
models.JournalEntry, models.FinanceControl, models.ExportRecord, models.JournalEntry, models.FinanceControl, models.ExportRecord,
models.SessionFile, models.ControlResult, models.PayoutReceipt, models.SessionFile,
) )
@ -168,12 +173,27 @@ def persist_aggregates(db: OrmSession, session_id: int, result: ProcessResult,
f.max_date = m.max_date f.max_date = m.max_date
f.currency = m.currency f.currency = m.currency
f.marketplace = m.marketplace f.marketplace = m.marketplace
# Control C1 evidence: what the worksheet declared vs what we consumed.
f.sheet_last_row = m.sheet_last_row
f.blank_rows_skipped = m.blank_rows_skipped
f.helper_rows_skipped = m.helper_rows_skipped
f.status = "invalid" if m.missing_required else "parsed" f.status = "invalid" if m.missing_required else "parsed"
if m.missing_required: if m.missing_required:
f.message = f"missing required columns: {m.missing_required}" f.message = f"missing required columns: {m.missing_required}"
# settlements (+ attach boundary transfer info) # Per-payout facts, keyed like the engine classifies: (marketplace, acct bucket, sid).
boundary_tx = {k: t for k, t in (cls.boundary_transfer or {}).items() if t} # Persisted for EVERY bucket with transfers — the Payouts screen reads received/in-transit
# from here, so recording it only for the boundary payout (as before) left every other
# payout's status NULL.
tx_info: dict[tuple[str, str, str], list] = {}
for t in (agg.transfers or []):
slot = tx_info.setdefault((t.marketplace, t.account_type, t.settlement_id),
[0.0, None, True])
slot[0] += t.amount
if t.txn_date and (slot[1] is None or t.txn_date > slot[1]):
slot[1] = t.txn_date
slot[2] = slot[2] and t.received
for (mkt, acct, sid), st in agg.settlements.items(): for (mkt, acct, sid), st in agg.settlements.items():
row = models.Settlement( row = models.Settlement(
session_id=session_id, marketplace=mkt, account_type=acct, settlement_id=sid, session_id=session_id, marketplace=mkt, account_type=acct, settlement_id=sid,
@ -181,11 +201,11 @@ def persist_aggregates(db: OrmSession, session_id: int, result: ProcessResult,
row_count=st.row_count, first_date=st.first_date, last_date=st.last_date, row_count=st.row_count, first_date=st.first_date, last_date=st.last_date,
status=st.status, status=st.status,
) )
t = boundary_tx.get((mkt, acct)) info = tx_info.get((mkt, acct, sid))
if t and t.settlement_id == sid: if info is not None:
row.transfer_amount = t.amount row.transfer_amount = info[0]
row.transfer_date = t.txn_date row.transfer_date = info[1]
row.transfer_received = t.received row.transfer_received = info[2]
db.add(row) db.add(row)
# receivable results (per account + TOTAL) # receivable results (per account + TOTAL)
@ -256,6 +276,13 @@ def _exceptions_from(result: ProcessResult) -> list[dict]:
for fld in m.missing_required: for fld in m.missing_required:
out.append({"category": "missing_column", "severity": "error", out.append({"category": "missing_column", "severity": "error",
"detail": f"Required field '{fld}' missing", "source": m.filename}) "detail": f"Required field '{fld}' missing", "source": m.filename})
for fld, cols in (getattr(m, "duplicate_fields", None) or {}).items():
cols_txt = ", ".join(f"{c}{f' ({t})' if t else ''}" for c, t in cols)
out.append({"category": "duplicate_column_mapping", "severity": "error",
"detail": (f"Columns {cols_txt} all map to '{fld}'. Only the first is "
f"used, so the others are excluded from every total — "
f"correct the header mapping before relying on this close."),
"source": m.filename})
for col, s in (getattr(m, "unmapped_amount_sums", None) or {}).items(): for col, s in (getattr(m, "unmapped_amount_sums", None) or {}).items():
if abs(s) > 0.005: if abs(s) > 0.005:
out.append({"category": "unmapped_amounts", "severity": "error", out.append({"category": "unmapped_amounts", "severity": "error",

View File

@ -5,6 +5,10 @@ python-calamine>=0.3.0
# API layer # API layer
fastapi==0.115.6 fastapi==0.115.6
# Pinned deliberately: fastapi 0.115.6 requires starlette <0.42, and Starlette 1.x dropped the
# `on_startup` argument from Router.__init__. An unpinned upgrade to 1.3.1 broke every route
# module at import time with "Router.__init__() got an unexpected keyword argument 'on_startup'".
starlette==0.41.3
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
python-multipart==0.0.20 python-multipart==0.0.20
pydantic==2.10.4 pydantic==2.10.4

View File

@ -2,10 +2,27 @@
from __future__ import annotations from __future__ import annotations
import os import os
import tempfile
from pathlib import Path from pathlib import Path
import pytest import pytest
# ---------------------------------------------------------------------------------------
# Redirect ALL test data to a throwaway directory — BEFORE anything imports app.config,
# which reads these variables once at module load and caches the paths.
#
# Without this the suite runs against the real production database: `app/config.py` falls
# back to `backend/data/ar_aging.db`, so every test that created a closing was writing into
# Finance's live data (75 sessions had accumulated there). Tests must never be able to touch
# a real closing.
#
# The names must match app/config.py exactly — AR_DB_PATH / AR_DATA_DIR. A near-miss such as
# "AR_DB_URL" silently does nothing and the tests quietly hit production again.
# ---------------------------------------------------------------------------------------
_TEST_DATA_DIR = Path(tempfile.mkdtemp(prefix="ar-aging-tests-"))
os.environ["AR_DATA_DIR"] = str(_TEST_DATA_DIR)
os.environ["AR_DB_PATH"] = str(_TEST_DATA_DIR / "test.db")
# Default: the project root two levels above ar-aging-app/backend. # Default: the project root two levels above ar-aging-app/backend.
_DEFAULT_SAMPLE_DIR = Path(__file__).resolve().parents[3] _DEFAULT_SAMPLE_DIR = Path(__file__).resolve().parents[3]
@ -34,3 +51,22 @@ def sample_workbook() -> str:
if not os.path.exists(p): if not os.path.exists(p):
pytest.skip(f"Sample workbook not found: {p}") pytest.skip(f"Sample workbook not found: {p}")
return p return p
@pytest.fixture(autouse=True, scope="session")
def _never_touch_production_data():
"""
Hard stop if the redirect above ever fails.
The suite creates and deletes closings, so pointing at the real database would destroy
Finance's data. Assert the isolation actually took effect rather than trusting it.
"""
from app.config import DATA_DIR, DB_PATH
assert str(DB_PATH).startswith(str(_TEST_DATA_DIR)), (
f"tests are pointed at {DB_PATH} — expected a temp path under {_TEST_DATA_DIR}. "
f"app/config.py reads AR_DB_PATH / AR_DATA_DIR; check those names."
)
assert str(DATA_DIR).startswith(str(_TEST_DATA_DIR)), (
f"tests would write uploads/exports to {DATA_DIR}, not a temp directory."
)
yield

View File

@ -0,0 +1,92 @@
"""
A/R aging bands.
The bands measure days **past due**, not days since the transaction. An Amazon receivable is
not due when the order posts it is due when its settlement disburses, roughly 14 days after
the settlement's last activity plus the clearing lag.
This distinction is the whole report. Banding by transaction date instead pushes a perfectly
normal biweekly settlement into 1-30 (on the Jan-2026 close that misfiled 9,556,111.11 of USA's
11,110,308 as overdue) and the aging stops meaning anything. Banding by due date keeps a healthy
month at ~100% Current matching the Finance workbook while a settlement Amazon is actually
holding still ages out of Current, which is the point.
"""
from __future__ import annotations
import datetime as dt
import pytest
# The test database is redirected in conftest.py, which runs before any test module is
# imported. (An earlier version set "AR_DB_URL" here — a name app/config.py does not read —
# so these tests wrote into the production database instead.)
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy.orm import Session as OrmSession # noqa: E402
from app.api.main import app # noqa: E402
from app.db import models # noqa: E402
from app.db.database import SessionLocal, init_db # noqa: E402
MONTH_END = dt.date(2026, 1, 31)
def _session_with_settlement(last_date: dt.date, amount: float = 100_000.0) -> int:
"""A processed closing holding one receivable settlement with the given last activity."""
init_db()
db: OrmSession = SessionLocal()
try:
s = models.Session(name="aging", reporting_month="2026-01", month_end_date=MONTH_END,
clearing_lag_days=2, status="processed")
db.add(s)
db.commit()
db.add(models.Settlement(
session_id=s.id, marketplace="USA", account_type="Standard Orders",
settlement_id="900", order_total=amount, transfer_total=0.0, row_count=1,
first_date=last_date, last_date=last_date, status="receivable"))
db.add(models.ReceivableResultRow(
session_id=s.id, marketplace="USA", account_type="TOTAL",
additional_sales=amount, reserve=0.0, receivable_local=amount,
fx_rate=1.0, receivable_usd=amount, currency="USD"))
db.commit()
return s.id
finally:
db.close()
@pytest.mark.parametrize("last_date,expected_band", [
# due = last activity + 14 (settlement cycle) + 2 (clearing lag); overdue vs 31 Jan 2026
(dt.date(2026, 1, 29), "Current"), # due 14 Feb — 14 days before it is even due
(dt.date(2026, 1, 15), "Current"), # due 31 Jan — due exactly today, not yet late
(dt.date(2026, 1, 14), "1-30"), # due 30 Jan — 1 day overdue
(dt.date(2025, 12, 20), "1-30"), # due 5 Jan — 26 days overdue
(dt.date(2025, 11, 20), "31-60"), # due 6 Dec — 56 days overdue
(dt.date(2025, 11, 1), "61-90"), # due 17 Nov — 75 days overdue
(dt.date(2025, 10, 15), "91-Over"), # due 31 Oct — 92 days overdue
])
def test_settlements_band_by_days_past_due(last_date, expected_band):
sid = _session_with_settlement(last_date)
with TestClient(app) as c:
row = c.get(f"/api/sessions/{sid}/aging").json()["rows"][0]
banded = {b: v for b, v in row.items() if b in
("Current", "1-30", "31-60", "61-90", "91-Over")}
hit = max(banded, key=lambda b: abs(banded[b]))
assert hit == expected_band, f"last activity {last_date} landed in {hit}, expected {expected_band}"
def test_normal_biweekly_settlement_stays_current():
"""The regression that matters: a healthy settlement must not read as overdue."""
sid = _session_with_settlement(dt.date(2026, 1, 29), amount=9_556_111.11)
with TestClient(app) as c:
row = c.get(f"/api/sessions/{sid}/aging").json()["rows"][0]
assert row["Current"] == pytest.approx(9_556_111.11, abs=0.01)
assert row["1-30"] == 0.0
def test_bands_always_tie_to_the_headline_receivable():
"""Reserve and rounding land in Current so the row still sums to the published figure."""
sid = _session_with_settlement(dt.date(2025, 11, 20), amount=100_000.0)
with TestClient(app) as c:
data = c.get(f"/api/sessions/{sid}/aging").json()
row = data["rows"][0]
assert sum(row[b] for b in data["bands"]) == pytest.approx(row["Total"], abs=0.01)
assert row["Total"] == pytest.approx(100_000.0, abs=0.01)

View File

@ -0,0 +1,247 @@
"""
Month-end controls: each one must actually FAIL when its defect is present.
A control that only ever passes is worse than no control it is a green light wired on.
The previous "Reconciled" status was exactly that (see core/controls.py), so every test here
introduces the real defect and asserts the close is blocked and no figure is published.
"""
from __future__ import annotations
import os
import tempfile
import pytest
from app.core import controls
from app.core.money import USD, CurrencyMismatch, Total
from app.core.settlements import aggregate
_TMP = tempfile.mkdtemp()
# --------------------------------------------------------------------- money / C6
def test_total_refuses_to_mix_currencies():
t = Total()
t.add(100.0, "EUR")
with pytest.raises(CurrencyMismatch):
t.add(100.0, "GBP")
def test_total_converts_to_usd():
t = Total(USD)
t.add_converted(230089.62, 1.185665)
t.add_converted(661888.13, 1.368908)
assert t.value == round(230089.62 * 1.185665 + 661888.13 * 1.368908, 2)
def test_c6_detects_a_local_currency_sum():
"""The Jan-2026 defect: locals summed (2,278,406.86) vs converted (2,723,065.30)."""
r = controls.c6_currency_integrity(2_278_406.86, 2_723_065.30)
assert r.status == controls.FAIL and r.blocking
assert "444,658.44" in r.detail
def test_c6_passes_when_surfaces_agree():
assert controls.c6_currency_integrity(2_723_065.30, 2_723_065.30).status == controls.PASS
# --------------------------------------------------------------------- C1
class _Meta:
def __init__(self, name, last_row, header_row, imported, helper=0, blank=0):
self.filename, self.sheet_last_row, self.header_row = name, last_row, header_row
self.imported_rows, self.helper_rows_skipped, self.blank_rows_skipped = (
imported, helper, blank)
@property
def expected_data_rows(self):
return max(0, self.sheet_last_row - self.header_row)
@property
def rows_accounted_for(self):
return self.imported_rows + self.helper_rows_skipped + self.blank_rows_skipped
def test_c1_fails_when_rows_go_missing():
"""A file that silently stops parsing half-way is invisible to a self-referential identity."""
r = controls.c1_source_row_count([_Meta("half.xlsx", 1008, 8, imported=500)])
assert r.status == controls.FAIL and r.blocking
assert "500 unexplained" in r.evidence[0]
def test_c1_passes_when_every_row_is_accounted_for():
metas = [_Meta("ok.xlsx", 1008, 8, imported=990, helper=1, blank=9)]
assert controls.c1_source_row_count(metas).status == controls.PASS
def test_c1_accepts_a_genuinely_empty_file():
"""Turkey Jan-2026: dimension A1:T7, header row 7, zero data rows — empty, not broken."""
assert controls.c1_source_row_count(
[_Meta("Turkey.xlsx", 7, 7, imported=0)]).status == controls.PASS
# --------------------------------------------------------------------- C2
def _journal(lines_total: float) -> dict:
return {"marketplace": "USA", "lines": [{"key": "Sales", "total": lines_total}]}
def test_c2_fails_when_an_amount_column_is_unmapped():
"""The Australia `fulfilment by amazon fees` class of bug: a column missing from the GL."""
r = controls.c2_column_completeness(_journal(1000.0), uploaded_total=1002_427.43)
assert r.status == controls.FAIL and r.blocking
def test_c2_passes_when_lines_reconcile_to_total():
assert controls.c2_column_completeness(
_journal(1000.0), uploaded_total=1000.0).status == controls.PASS
# --------------------------------------------------------------------- C3
def _rec(**kw):
base = {"settlement_id": "100", "txn_type": "Order", "account_type": "Standard Orders",
"total": 10.0, "_date": None, "_type_en": "Order", "_marketplace": "USA"}
base.update(kw)
return base
def test_c3_fails_on_an_unrecognized_account_type():
agg = aggregate([_rec(account_type="Mystery Orders", total=5000.0)])
r = controls.c3_bucket_completeness(agg)
assert r.status == controls.FAIL and r.blocking
assert "5,000.00" in r.detail
def test_c3_fails_on_a_non_numeric_settlement_id():
agg = aggregate([_rec(settlement_id="", total=1234.56)])
r = controls.c3_bucket_completeness(agg)
assert r.status == controls.FAIL and r.blocking
assert "1,234.56" in r.detail
def test_c3_passes_on_clean_rows():
agg = aggregate([_rec(), _rec(settlement_id="101", account_type="Invoiced Orders")])
assert controls.c3_bucket_completeness(agg).status == controls.PASS
def test_c3_ignores_transfers_without_an_account_type():
"""Amazon leaves account type blank on payouts everywhere except the USA — that's normal."""
agg = aggregate([_rec(txn_type="Transfer", _type_en="Transfer",
account_type="", total=-9999.0)])
assert controls.c3_bucket_completeness(agg).status == controls.PASS
# --------------------------------------------------------------------- C5
class _Fx:
def __init__(self, marketplace, currency, rate, confirmed_by="", confirmed_month="",
source="default (Jan-26 workbook)"):
self.marketplace, self.currency, self.rate = marketplace, currency, rate
self.confirmed_by, self.confirmed_month, self.source = (
confirmed_by, confirmed_month, source)
def test_c5_blocks_a_seeded_default_rate():
r = controls.c5_fx_confirmed([_Fx("Germany", "EUR", 1.185665)], "2026-07", ["Germany"])
assert r.status == controls.FAIL and r.blocking
assert "not confirmed" in r.evidence[0]
def test_c5_blocks_a_rate_confirmed_for_another_month():
"""The stale-FX defect: January's rate silently valuing a July close."""
fx = [_Fx("Germany", "EUR", 1.185665, confirmed_by="cfo", confirmed_month="2026-01")]
r = controls.c5_fx_confirmed(fx, "2026-07", ["Germany"])
assert r.status == controls.FAIL
assert "2026-01" in r.evidence[0]
def test_c5_passes_when_confirmed_for_this_month():
fx = [_Fx("Germany", "EUR", 1.16, confirmed_by="cfo", confirmed_month="2026-07")]
assert controls.c5_fx_confirmed(fx, "2026-07", ["Germany"]).status == controls.PASS
def test_c5_does_not_ask_anyone_to_confirm_usd_at_parity():
fx = [_Fx("USA", "USD", 1.0)]
assert controls.c5_fx_confirmed(fx, "2026-07", ["USA"]).status == controls.PASS
# --------------------------------------------------------------------- blocking
def test_only_error_severity_blocks():
warn = controls.c4_dual_method([
{"marketplace": "USA", "currency": "USD",
"settlement_closing": 100.0, "closing_local": 900.0},
])
assert warn.status == controls.FAIL and not warn.blocking # warning, not a block
assert controls.blocking_summary([warn]) == ""
def test_blocking_summary_names_the_failed_control():
bad = controls.c6_currency_integrity(1.0, 2.0)
assert "C6" in controls.blocking_summary([bad])
# --------------------------------------------------------------------- advertising
def test_advertising_detected_from_description_not_type():
"""
Amazon books advertising as type "Service Fee" with only the DESCRIPTION saying
"Cost of advertising". Canada Jan-2026 had CA$36,948.43 of it sitting in "Other
service charges" with the Advertising line at 0.00 because the old check looked at
the type only.
"""
from app.core.journal import _contribute_components, COMPONENT_KEYS
comp = {k: 0.0 for k in COMPONENT_KEYS}
_contribute_components(
{"txn_type": "Service Fee", "description": "Cost of advertising", "other": -36948.43,
"total": -36948.43},
comp, "Service Fee")
assert comp["advertising"] == -36948.43
assert comp["other_service_charges"] == 0.0
def test_localized_advertising_descriptions_route_to_advertising():
from app.core.i18n import is_advertising_like
for desc in ("Cost of advertising", "Werbekosten", "Coût de la publicité",
"Costo della pubblicità", "Coste de publicidad", "Sponsored Products charge"):
assert is_advertising_like("Service Fee", desc), desc
# a plain service fee must NOT be classified as advertising
assert not is_advertising_like("Service Fee", "Subscription fee")
assert not is_advertising_like("Service Fee", None)
def test_uk_advertising_in_other_transaction_fees_column():
"""UK books "Cost of Advertising" in `other transaction fees` (UK Jan-26: -153,176.09),
not in `other` like North America both columns must route to Advertising."""
from app.core.journal import _contribute_components, COMPONENT_KEYS
comp = {k: 0.0 for k in COMPONENT_KEYS}
_contribute_components(
{"txn_type": "Service Fee", "description": "Cost of Advertising",
"other_transaction_fees": -153176.09, "total": -153176.09},
comp, "Service Fee")
assert comp["advertising"] == -153176.09
assert comp["other_transaction_fees"] == 0.0
# …and a NON-advertising row keeps its other_transaction_fees where they belong.
comp2 = {k: 0.0 for k in COMPONENT_KEYS}
_contribute_components(
{"txn_type": "Order", "description": "some product",
"other_transaction_fees": -10.0, "total": -10.0},
comp2, "Order")
assert comp2["other_transaction_fees"] == -10.0
assert comp2["advertising"] == 0.0
# --------------------------------------------------------------------- definitions
def test_every_component_has_a_definition():
"""The (i) buttons must cover every line the Finance Summary can show. A new component
without a definition ships an unexplained number fail here instead."""
from app.core.definitions import DEFINITIONS
from app.core.journal import COMPONENT_KEYS
missing = [k for k in COMPONENT_KEYS if k not in DEFINITIONS]
assert not missing, f"components with no (i) definition: {missing}"
# …and the aggregate/tab-level figures the UI explains.
for key in ("gross_revenue", "net_revenue", "opening_balance", "closing_receivable",
"settlement_closing", "disbursements", "in_transit_payouts",
"closing_receivable_usd", "receivable_orders", "paid_orders",
"transfers_total", "aging_basis", "settlement_status", "uploaded_total"):
assert key in DEFINITIONS, key
for k, d in DEFINITIONS.items():
assert d.get("formula") and d.get("source"), f"{k} definition is incomplete"

View File

@ -0,0 +1,162 @@
"""
Bank receipts for Amazon payouts.
Amazon's Transfer row says when a payout was INITIATED; the bank credit lands 3-5 working
days later. Finance records the bank date per payout, and that record decides received vs
in-transit (received bank date month-end) overriding the clearing-lag heuristic.
payout_mode=manual turns the heuristic off entirely: no receipt means not received.
Fixture (make_amazon_xlsx, month-end 2026-01-31, lag 2 cutoff Jan 29):
Standard: transfers sid 200 (Jan 6, -1000, received) · sid 300 (Jan 30, in transit)
orders sid 100 (1000, paid) · 200 (2000) · 300 (500)
Invoiced: transfer sid 250 (Jan 12, -300, received) · orders 150 (300, paid) · 250 (80)
default receivable local = 2000 + 500 + 80 = 2580
"""
from __future__ import annotations
import os
import tempfile
from fastapi.testclient import TestClient
from app.api.main import app
from app.db.database import init_db
from tests.test_excel_export import make_amazon_xlsx
_TMP = tempfile.mkdtemp(prefix="ar_receipt_test_")
STD, INV = "Standard Orders", "Invoiced Orders"
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,
}).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 _reprocess(c, sid: int) -> None:
assert c.post(f"/api/sessions/{sid}/process").status_code == 200
assert c.get(f"/api/sessions/{sid}/status").json()["status"] == "processed"
def _usa_receivable(c, sid: int) -> float:
rows = c.get(f"/api/sessions/{sid}/receivable").json()
return round(sum(r["receivable_local"] for r in rows
if r["marketplace"] == "USA" and r["account_type"] != "TOTAL"), 2)
def test_payout_list_shows_amazon_dates_and_current_status():
init_db()
with TestClient(app) as c:
sid = _fresh(c, "list payouts")
data = c.get(f"/api/sessions/{sid}/payouts").json()
assert data["payout_mode"] == "auto" and not data["needs_reprocess"]
by_sid = {p["settlement_id"]: p for p in data["payouts"]}
assert set(by_sid) == {"200", "250", "300"}
assert by_sid["200"]["amount"] == -1000.0
assert by_sid["200"]["amazon_date"] == "2026-01-06"
assert by_sid["200"]["received_now"] is True # lag heuristic
assert by_sid["300"]["received_now"] is False # Jan 30 > cutoff Jan 29
assert by_sid["200"]["bank_date"] is None
def test_bank_date_overrides_the_lag_in_both_directions():
"""A February bank date pulls a 'received' payout back to in-transit (and the
receivable up); a Jan-31 bank date marks a payout received that the lag called
in-transit Amazon initiated it Jan 30, the bank got it a day later."""
init_db()
with TestClient(app) as c:
sid = _fresh(c, "override both ways")
assert _usa_receivable(c, sid) == 2580.0
# Amazon initiated sid=200's payout Jan 6, but the bank only got it Feb 4:
# the payout was NOT received this month, so settlement 100 also stays open.
r = c.put(f"/api/sessions/{sid}/payouts/receipts", json=[
{"marketplace": "USA", "account_type": STD, "settlement_id": "200",
"bank_date": "2026-02-04", "entered_by": "tester"},
]).json()
assert r["saved"] == 1 and r["needs_reprocess"]
assert c.get(f"/api/sessions/{sid}/status").json()["session"]["needs_reprocess"] is True
# The list previews the effect before re-processing…
p = {x["settlement_id"]: x for x in
c.get(f"/api/sessions/{sid}/payouts").json()["payouts"]}
assert p["200"]["received_now"] is True and p["200"]["received_next_run"] is False
# …and re-processing applies it: Std boundary gone → 1000+2000+500+80 = 3580.
_reprocess(c, sid)
assert _usa_receivable(c, sid) == 3580.0
assert c.get(f"/api/sessions/{sid}/status").json()["session"]["needs_reprocess"] is False
# Remove that receipt; enter one for sid=300: initiated Jan 30, bank Jan 31 —
# received by month-end even though the lag heuristic said in-transit.
c.put(f"/api/sessions/{sid}/payouts/receipts", json=[
{"marketplace": "USA", "account_type": STD, "settlement_id": "200",
"bank_date": None},
{"marketplace": "USA", "account_type": STD, "settlement_id": "300",
"bank_date": "2026-01-31"},
])
_reprocess(c, sid)
# Boundary Std moves to 300 → only sid 300 receivable (500) + Invoiced 80.
assert _usa_receivable(c, sid) == 580.0
def test_manual_mode_counts_only_bank_dated_payouts():
"""The user's 'remove the lag' mode: without a receipt a payout is not received."""
init_db()
with TestClient(app) as c:
sid = _fresh(c, "manual mode")
r = c.put(f"/api/sessions/{sid}/payouts/mode", json={"mode": "manual"}).json()
assert r["payout_mode"] == "manual" and r["needs_reprocess"]
_reprocess(c, sid)
# No receipts: nothing received → no boundary → everything is receivable.
assert _usa_receivable(c, sid) == 3880.0 # Std 1000+2000+500, Inv 300+80
# Record the two January bank credits; the Jan-30 payout stays in transit.
c.put(f"/api/sessions/{sid}/payouts/receipts", json=[
{"marketplace": "USA", "account_type": STD, "settlement_id": "200",
"bank_date": "2026-01-08", "bank_amount": -1000.0},
{"marketplace": "USA", "account_type": INV, "settlement_id": "250",
"bank_date": "2026-01-14"},
])
_reprocess(c, sid)
assert _usa_receivable(c, sid) == 2580.0 # back to the default split
def test_daily_ledger_places_payout_on_its_bank_date():
init_db()
with TestClient(app) as c:
sid = _fresh(c, "ledger bank date")
# Amazon initiated Jan 6; the bank received it Jan 8.
c.put(f"/api/sessions/{sid}/payouts/receipts", json=[
{"marketplace": "USA", "account_type": STD, "settlement_id": "200",
"bank_date": "2026-01-08"},
])
detail = c.get(f"/api/sessions/{sid}/ledger-detail").json()
per = {p["key"]: p for p in detail["periods"]}
assert per["2026-01-08"]["payouts_received"] == -1000.0
assert per["2026-01-08"]["bank_dated"] == -1000.0
assert "2026-01-06" not in per or per["2026-01-06"]["payouts_received"] == 0.0
def test_bank_amount_variance_raises_a_warning():
init_db()
with TestClient(app) as c:
sid = _fresh(c, "variance")
c.put(f"/api/sessions/{sid}/payouts/receipts", json=[
{"marketplace": "USA", "account_type": STD, "settlement_id": "200",
"bank_date": "2026-01-09", "bank_amount": -987.65}, # bank fee shaved it
{"marketplace": "USA", "account_type": STD, "settlement_id": "9999999",
"bank_date": "2026-01-09"}, # typo'd settlement id
])
_reprocess(c, sid)
cats = [e["category"] for e in c.get(f"/api/sessions/{sid}/exceptions").json()]
assert "bank_amount_variance" in cats
assert "unmatched_bank_receipt" in cats

View File

@ -108,6 +108,22 @@ def test_per_market_endpoints():
assert c.post(f"/api/sessions/{sid}/files", assert c.post(f"/api/sessions/{sid}/files",
files={"files": (os.path.basename(path), fh)}).status_code == 200 files={"files": (os.path.basename(path), fh)}).status_code == 200
assert c.post(f"/api/sessions/{sid}/process").status_code == 200 assert c.post(f"/api/sessions/{sid}/process").status_code == 200
# A EUR marketplace on a seeded (Jan-26 snapshot) rate that nobody confirmed must
# BLOCK: control C5 refuses to publish a converted figure at an unverified rate.
assert c.get(f"/api/sessions/{sid}/status").json()["status"] == "blocked"
ctrl = c.get(f"/api/sessions/{sid}/controls").json()
assert ctrl["blocked"]
c5 = next(r for r in ctrl["controls"] if r["key"] == "C5")
assert c5["status"] == "fail" and any("Netherlands" in e for e in c5["evidence"])
# …and no receivable figure is released while blocked.
assert c.get(f"/api/sessions/{sid}/summary").json()["blocked"] is True
assert c.post(f"/api/sessions/{sid}/export?kind=summary").status_code == 409
# Confirming the rates for this reporting month clears the block.
after_confirm = c.post(f"/api/sessions/{sid}/fx/confirm-all",
json={"confirmed_by": "test-controller"}).json()
assert not after_confirm["blocked"], after_confirm["blocked_reason"]
assert c.get(f"/api/sessions/{sid}/status").json()["status"] == "processed" assert c.get(f"/api/sessions/{sid}/status").json()["status"] == "processed"
mv = c.get(f"/api/sessions/{sid}/ar-movement").json() mv = c.get(f"/api/sessions/{sid}/ar-movement").json()

View File

@ -0,0 +1,142 @@
"""
The two readers must be interchangeable.
`AR_USE_CALAMINE` selects between the Rust-backed CalamineReader (default) and the
pure-Python streaming TransactionReader (low-memory fallback). An auditor re-performing a
close must get the same number either way, so any behavioural difference between them is a
defect not a performance trade-off.
Two real divergences lived here before this file existed:
* sheet selection tie-break score-only/first-wins vs (score, height)/tallest-wins, so the
two readers could pick DIFFERENT worksheets out of the same workbook;
* row emptiness one tested the CONVERTED value (empty amount cells become 0.0, so every
row qualified) while the other tested the source cell, so they emitted different row sets.
"""
from __future__ import annotations
import os
import tempfile
import pytest
from app.core.calamine_reader import CalamineReader
from app.core.xlsx_reader import TransactionReader
from .test_excel_export import make_amazon_xlsx
from .test_per_market import _make_dutch_file
_TMP = tempfile.mkdtemp()
def _make_blank_row_file(path: str) -> None:
"""
Real data rows with empty rows INTERLEAVED between them.
This is the exact shape the two readers disagreed on. CalamineReader tested the CONVERTED
value, and every amount field converts an empty cell to 0.0, so a row of empty strings
still qualified it emitted a phantom row 11 that TransactionReader dropped. Row counts,
and the duplicate count (identical blank rows hash alike), therefore depended on which
reader ran.
The blanks must be INTERLEAVED, not trailing: calamine trims trailing empty rows itself,
so a trailing-blanks fixture passes even against the unfixed reader.
"""
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "USA Amazon Transactions"
for i in range(7):
ws.append([f"preamble {i}"])
ws.append(["date/time", "settlement id", "type", "order id", "sku", "account type",
"marketplace", "product sales", "total"])
def row(i):
return [f"Jan {i + 1}, 2026 1:00:00 PM PST", 500, "Order", f"o{i}", "sku",
"Standard Orders", "amazon.com", 10.0, 10.0]
ws.append(row(0))
ws.append([None] * 9) # openpyxl writes no cells for these
ws.append([""] * 9) # …but empty strings ARE materialized — the divergent case
ws.append(row(1))
ws.append(row(2))
wb.save(path)
def _fixtures() -> list[str]:
usa = os.path.join(_TMP, "USA equivalence.xlsx")
nl = os.path.join(_TMP, "Netherlands Amazon Transactions January, 2026.xlsx")
blanks = os.path.join(_TMP, "USA blank rows.xlsx")
make_amazon_xlsx(usa, order_rows=10)
_make_dutch_file(nl)
_make_blank_row_file(blanks)
return [usa, nl, blanks]
def _read(reader) -> dict:
"""The FINANCIAL output — everything that could change a reported number."""
reader.detect()
rows = list(reader.iter_records())
m = reader.file_meta
out = {
"sheet": m.data_sheet,
"header_row": m.header_row,
"rows": len(rows),
"sum_total": round(sum(float(r.get("total") or 0.0) for r in rows), 6),
"helper_rows_skipped": m.helper_rows_skipped,
"mapped_fields": sorted(reader.column_mapping.field_to_col),
"source_rows": [r.get("_source_row") for r in rows],
}
reader.close()
return out
@pytest.mark.parametrize("path", _fixtures())
def test_readers_produce_identical_output(path):
cal = _read(CalamineReader(path))
itp = _read(TransactionReader(path))
assert cal == itp, (
f"{os.path.basename(path)}: the two readers disagree.\n"
f" calamine : {cal}\n iterparse: {itp}"
)
@pytest.mark.parametrize("path", _fixtures())
def test_each_reader_accounts_for_every_row_it_declares(path):
"""
Control C1's invariant, checked per reader.
The declared extent itself is deliberately NOT compared across readers: calamine trims
trailing blank rows from `total_height` while `<dimension>` counts them, so on the
trailing-blanks fixture one sees 11 rows and the other 15. Both are right, and each stays
internally consistent which is all C1 needs.
"""
for reader in (CalamineReader(path), TransactionReader(path)):
reader.detect()
list(reader.iter_records())
m = reader.file_meta
assert m.sheet_last_row > 0, f"{type(reader).__name__} declared no extent"
assert m.rows_accounted_for == m.expected_data_rows, (
f"{os.path.basename(path)} via {type(reader).__name__}: "
f"declared {m.expected_data_rows}, accounted for {m.rows_accounted_for}"
)
reader.close()
def test_blank_rows_are_dropped_by_both_readers():
"""
Guards the specific divergence: 3 real rows with 2 blanks between them must read as
exactly 3 rows, from source rows 9/12/13, under either reader.
Verified non-vacuous with the pre-fix `has_value` logic CalamineReader returns 4 rows
(source rows 9/11/12/13) and this fails.
"""
path = _fixtures()[2]
for R in (CalamineReader, TransactionReader):
r = R(path)
r.detect()
rows = list(r.iter_records())
assert [x["_source_row"] for x in rows] == [9, 12, 13], (
f"{R.__name__} emitted rows {[x['_source_row'] for x in rows]}, expected [9, 12, 13]"
)
assert round(sum(float(x.get("total") or 0.0) for x in rows), 2) == 30.0
r.close()

View File

@ -10,8 +10,9 @@ from __future__ import annotations
import os import os
import tempfile import tempfile
# Fixture .xlsx files only — data/DB isolation is guaranteed centrally by conftest.py, which
# must stay the single owner of AR_DATA_DIR (overriding it here trips the isolation guard).
_TMP = tempfile.mkdtemp(prefix="ar_lifecycle_test_") _TMP = tempfile.mkdtemp(prefix="ar_lifecycle_test_")
os.environ["AR_DATA_DIR"] = _TMP
from fastapi.testclient import TestClient # noqa: E402 from fastapi.testclient import TestClient # noqa: E402
@ -149,3 +150,105 @@ def test_carry_forward_with_no_prior_falls_back_to_zero():
s = c.get(f"/api/sessions/{sid}").json() s = c.get(f"/api/sessions/{sid}").json()
assert s["opening_mode"] == "zero" assert s["opening_mode"] == "zero"
assert c.delete(f"/api/sessions/{sid}").status_code == 200 assert c.delete(f"/api/sessions/{sid}").status_code == 200
# --------------------------------------------------------------- opening worksheet
def test_opening_worksheet_shows_variance_and_saving_revalidates():
"""
The all-markets worksheet: every marketplace's opening in one call, with the variance the
roll-forward produces against the settlement method, and the implied opening that would
close it. Saving an opening must re-run the month-end controls (C4 feeds off it).
"""
init_db()
with TestClient(app) as c:
sid = _processed_session(c, "worksheet", "2026-07-31")
ws = c.get(f"/api/sessions/{sid}/opening-balances/worksheet").json()
assert ws["available"] and ws["all_zero"]
row = next(r for r in ws["rows"] if r["marketplace"] == "USA")
# roll-forward = opening + net revenue + payouts(negative); with opening 0 the
# variance against the settlement closing is closed exactly by `implied_opening`.
assert row["opening"] == 0.0
assert row["implied_opening"] == round(
row["settlement_closing"] - row["movement"], 2)
# Save an opening -> mode flips to manual, movement shifts by exactly that amount,
# and the controls have been re-evaluated (C4 present with a fresh verdict).
r = c.put(f"/api/sessions/{sid}/opening-balances",
json=[{"marketplace": "USA", "amount": 1234.56,
"reason": "prior close", "source": "manual"}])
assert r.status_code == 200
ws2 = c.get(f"/api/sessions/{sid}/opening-balances/worksheet").json()
row2 = next(r for r in ws2["rows"] if r["marketplace"] == "USA")
assert row2["opening"] == 1234.56
assert not ws2["all_zero"]
assert ws2["mode"] == "manual"
assert round(row2["roll_forward_closing"] - row["roll_forward_closing"], 2) == 1234.56
ctrl = c.get(f"/api/sessions/{sid}/controls").json()
assert any(x["key"] == "C4" for x in ctrl["controls"])
def test_carry_forward_fills_next_month_opening_automatically():
"""User requirement: with carry-forward, last month's closing IS next month's opening —
no manual entry."""
init_db()
with TestClient(app) as c:
prior = _processed_session(c, "june close", "2026-06-30")
prior_ws = c.get(f"/api/sessions/{prior}/opening-balances/worksheet").json()
prior_closing = next(r for r in prior_ws["rows"]
if r["marketplace"] == "USA")["roll_forward_closing"]
nxt = _processed_session(c, "july close", "2026-07-31",
opening_mode="carry_forward",
opening_source_session_id=prior)
ws = c.get(f"/api/sessions/{nxt}/opening-balances/worksheet").json()
row = next(r for r in ws["rows"] if r["marketplace"] == "USA")
assert row["source"] == "carried_forward"
assert row["opening"] == round(prior_closing, 2)
assert ws["mode"] == "carry_forward"
# --------------------------------------------------------------- journal sign-off
def test_journal_review_approve_publishes_to_accounts_summary():
"""Review → approve is the publish step; re-processing withdraws the sign-off."""
init_db()
with TestClient(app) as c:
sid = _processed_session(c, "signoff", "2026-07-31")
j = c.get(f"/api/sessions/{sid}/journal").json()
assert j["available"] and j["approved_by"] == ""
# Advertising line exists and Transfer is still in the payload (movement needs it).
keys = [ln["key"] for ln in j["lines"]]
assert "Advertising Cost" in keys and "Transfer" in keys
# The accrual balancing figure equals -(sum of non-Transfer lines) = net revenue.
non_transfer = sum(ln["total"] for ln in j["lines"] if ln["key"] != "Transfer")
assert j["receivable_accrual"]["total"] == round(-non_transfer, 2)
# GL accounts carry the journal's own marketplace, not a hardcoded USA.
assert any("Amazon USA" in ln["gl_account"] for ln in j["lines"]) # single-market USA
# Approval without review is refused.
assert c.post(f"/api/sessions/{sid}/journal/approve",
json={"name": "boss"}).status_code == 400
# Nothing is published yet.
assert c.get("/api/accounts-summary").json()["available"] is False
assert c.post(f"/api/sessions/{sid}/journal/review",
json={"name": "A. Accountant"}).status_code == 200
approved = c.post(f"/api/sessions/{sid}/journal/approve",
json={"name": "B. Controller"}).json()
assert approved["approved_by"] == "B. Controller"
summ = c.get("/api/accounts-summary").json()
assert summ["available"]
assert summ["months"][0]["approved_by"] == "B. Controller"
assert "Transfer" not in summ["line_keys"]
cell = summ["cells"][0]
assert cell["marketplace"] == "USA"
assert cell["receivable"] == j["receivable_accrual"]["total"]
# Re-processing changes the numbers -> the sign-off clears and the month unpublishes.
assert c.post(f"/api/sessions/{sid}/process").status_code == 200
assert c.get(f"/api/sessions/{sid}/status").json()["status"] == "processed"
j2 = c.get(f"/api/sessions/{sid}/journal").json()
assert j2["approved_by"] == "" and j2["reviewed_by"] == ""
assert c.get("/api/accounts-summary").json()["available"] is False

View File

@ -64,11 +64,12 @@ JOURNAL = {
} }
def _build(tmp_path): def _build(tmp_path, controls=None):
out = str(tmp_path / "summary.xlsx") out = str(tmp_path / "summary.xlsx")
export_summary_workbook(out, SUMMARY, CONTROL, JOURNAL, export_summary_workbook(out, SUMMARY, CONTROL, JOURNAL,
{"session_name": "Jan close", "entry_no": "22283", {"session_name": "Jan close", "entry_no": "22283",
"files": [{"filename": "a.xlsx", "rows": 10, "dates": "..", "sha256": "abc"}]}) "files": [{"filename": "a.xlsx", "rows": 10, "dates": "..", "sha256": "abc"}]},
controls=controls)
return openpyxl.load_workbook(out) return openpyxl.load_workbook(out)
@ -80,7 +81,30 @@ def _cells(ws):
def test_sheets_present(tmp_path): def test_sheets_present(tmp_path):
wb = _build(tmp_path) wb = _build(tmp_path)
assert wb.sheetnames == ["Finance Summary", "AR Ledger", "Reconciliation Control", assert wb.sheetnames == ["Finance Summary", "AR Ledger", "Reconciliation Control",
"Category Totals", "Audit Trail"] "Category Totals", "Month-End Controls", "Audit Trail"]
def test_month_end_controls_sheet_carries_the_evidence(tmp_path):
"""The workbook must stand alone as audit evidence — which controls ran, and their result."""
wb = _build(tmp_path, controls={
"controls": [
{"key": "C2", "label": "Column completeness", "status": "pass",
"severity": "error", "detail": "GL lines reconcile to the source `total` column.",
"evidence": []},
{"key": "C5", "label": "FX rates confirmed", "status": "fail", "severity": "error",
"detail": "1 marketplace has no FX rate confirmed for 2026-01.",
"evidence": ["Germany: rate 1.185665 (EUR) is a seeded default"]},
],
})
flat = [v for row in _cells(wb["Month-End Controls"]) for v in row]
assert "C2" in flat and "C5" in flat
assert "PASS" in flat and "FAIL" in flat
assert any(isinstance(v, str) and "seeded default" in v for v in flat)
def test_month_end_controls_sheet_when_none_recorded(tmp_path):
flat = [v for row in _cells(_build(tmp_path)["Month-End Controls"]) for v in row]
assert any(isinstance(v, str) and "No controls recorded" in v for v in flat)
def test_finance_summary_key_figures(tmp_path): def test_finance_summary_key_figures(tmp_path):

View File

@ -0,0 +1,200 @@
# Audit Report — Amazon A/R Aging Dashboard
**Scope:** correctness of every receivable figure the dashboard publishes.
**Performed:** 31 July 2026 · **Basis:** full source-code review of the calculation engine plus
re-performance against the January 2026 production dataset.
**Dataset used:** closing "Jan 2026 Test" (session 1) — 16 Amazon Custom Unified Transaction
files, 13 marketplaces with activity plus Turkey, **3,399,517 transaction rows**, reporting month
2026-01, month-end 2026-01-30, clearing-lag 2 days.
---
## 1. Conclusion
The receivable **calculation** is sound. Re-performance reproduces the Finance team's manual
workbook: **USA January 2026 = 11,110,433** (`Detail!D11`, with the 125.44 reserve applied) and the
column-completeness test ties to the penny on every one of the 16 source files.
The **controls around it were not.** Three defects allowed a wrong or missing number to reach the
ledger without any warning, one of which was actively misstating the group receivable by
**USD 444,658.44** on the January close. The status the dashboard displayed as assurance —
"Reconciled" — was arithmetically incapable of failing.
All confirmed defects are fixed, and six independent controls now run on every close. **No
receivable value changed as a result of the fixes** (see §5).
---
## 2. Findings
Severity is ledger impact. "Active" = present in the January 2026 data. "Latent" = the code path
exists and will misstate when the triggering condition occurs, but did not occur in this dataset.
| # | Finding | Status | Measured exposure |
|---|---|---|---|
| **F1** | **Reconciliation Control added different currencies together** — each marketplace's closing was summed in its own local currency (USD + EUR + GBP + PLN + SEK + CAD + AUD as one figure). This was the figure gating Finance sign-off. | **Active** | **USD 444,658.44 understated** (2,278,406.86 vs 2,723,065.30) |
| **F3** | **The "Reconciled" status could not fail.** `uploaded_total` was accumulated from the same record stream that filled the three buckets it was compared against — one sum written twice. | **Active** | Reported "Reconciled" while F1 was live |
| **F8** | **Payout rows were never classified.** The post-classification `UPDATE` matched on `account_type`, but the aggregator bucketed blank account types as `(unspecified)` while the database stored `""`. | **Active** | **25 rows carrying 4,782,085.25** left with a NULL status |
| **F2** | **Stale FX applied silently to any month.** `DEFAULT_FX_USD` is a hardcoded January-2026 snapshot, merged in for every closing. A July close would have valued EUR at January's rate and said nothing. | **Active (latent misstatement)** | 12 of 13 marketplaces on unconfirmed defaults |
| **F13** | **A partial `PUT /fx` deleted every rate not named in it** (delete-all-then-insert). Discovered during verification: a one-marketplace update reduced 14 stored rates to 1, after which the close silently fell back to hardcoded defaults. | **Active** | 13 of 14 rates destroyed by one call |
| **F4** | Order rows with an unrecognized `account type` are excluded from the receivable with no warning. | **Latent** | **0 rows** — all 3,399,517 rows resolved cleanly |
| **F5** | Order rows with a blank/non-numeric settlement id sort to 1 and are silently classified "paid". | **Latent** | **0 rows** |
| **F6** | **The two parsers were not interchangeable** — different sheet-selection tie-break, and different row-emptiness rules (one tested the converted value, where empty amount cells become `0.0`, so every row qualified). | **Latent** | Readers agreed on all 16 real files; divergence reproduced on a synthetic fixture (4 rows vs 3) |
| **F7** | The journal pass re-read the files independently of the settlement pass, with a different field set and ignoring per-file marketplace overrides. | **Latent** | No override in use on this close |
| **F9** | Two headers mapping to the same field: the second column was silently discarded. | **Latent** | No collisions in these files |
| **F10** | Sign-off did not invalidate when the numbers changed — a closing could read "verified by X" against figures X never saw. | **Active (process risk)** | — |
| **F11** | The Aging tab was not an aging: 100% was forced into "Current" unconditionally, so a settlement Amazon was holding could never surface. Now banded by **days past due** (see below). | **Active (presentation)** | Bands unchanged for Jan-2026 (all Current); a held settlement now ages |
| **F12** | Currency fell back to a bare `"USD"` when a marketplace had no result row — EUR amounts could render labelled USD. | **Latent** | — |
### What was already correct
Worth stating plainly, because it bounds the exposure:
- **Column completeness ties exactly.** Σ(GL lines) Σ(source `total` column) = **0.00** across all
16 files. No amount column is unmapped, double-mapped, or dropped.
- **Bucket completeness is clean.** Every one of the 3,399,517 rows resolves to a recognized
account type and a numeric settlement id.
- **The two readers agreed** on all 16 real files, so no past close depended on which parser ran.
- **The receivable calculation itself matches the manual workbook** to the penny.
### Turkey — an empty file is indistinguishable from a failed one
`Turkey Amazon Transactions January, 2026.xlsx` is 10.7 KB with worksheet extent `A1:T7`: six
preamble rows, one header row, **no data rows**. Turkey genuinely had no January activity, so this
is *not* a misstatement.
It is, however, the clearest illustration of the core problem. Eleven Turkish amount headers
(`ürün satışları`, `satış ücretleri`, `Amazon Lojistik ücretleri` …) are unmapped, and the
`unmapped_amounts` safeguard stayed silent — because it only sums over rows that were read, and no
rows were read. A file that fails to parse produces exactly the same silent zero as a file with
nothing in it. Control **C1** now separates the two by checking the worksheet's own declared extent.
---
## 3. Controls now in place
Six controls run automatically at the end of every close and on demand. Each compares the engine's
output against **something the engine did not produce** — that independence is what allows them to
fail. A failure at error severity puts the closing in `blocked`: `/summary`, `/finance-summary`,
`/aging` and both Excel exports withhold the figure and return the reason instead.
| Control | Checks against | Fails when |
|---|---|---|
| **C1 Source row count** | the worksheet's own `<dimension>` | rows read ≠ rows the file declares |
| **C2 Column completeness** | Amazon's own `total` column | Σ(GL lines) ≠ Σ(`total`) — a column unmapped, double-mapped, or newly added |
| **C3 Bucket completeness** | the receivable filter itself | a money-carrying order row has no recognized account type or settlement id |
| **C4 Dual-method agreement** | the settlement method vs the roll-forward | the two closing methods diverge (warning — a first close legitimately differs) |
| **C5 FX confirmed** | a human, for this reporting month | any non-USD rate is a seeded default or was confirmed for a different month |
| **C6 Currency integrity** | the All-Markets roll-up | the two group totals disagree — i.e. someone added currencies without converting |
| **C7 Reader equivalence** | the other parser | the two readers disagree on any fixture (CI-time, `tests/test_reader_equivalence.py`) |
**Design rule:** a number that cannot be trusted is never displayed. A missing number cannot be
posted to the ledger; a wrong one can.
---
## 4. Result on the January 2026 close
Re-run after the fixes, the closing **blocked** on C5 — 12 marketplaces valued at unconfirmed
January-2026 default rates. After Finance confirmed the rates for the reporting month, it released:
```
C1 Source row count PASS 16 file(s) fully accounted for
C2 Column completeness PASS GL lines tie to the source `total` column (difference 0.00)
C3 Bucket completeness PASS every order row resolves to a receivable bucket
C4 Dual-method agreement REVIEW 13 marketplaces disagree (see below) — warning, non-blocking
C5 FX rates confirmed PASS confirmed for 2026-01
C6 Currency integrity PASS both group roll-ups agree at 2,723,065.30 USD
```
### C4 needs your attention
The two closing methods disagree on every marketplace, most starkly on USA:
| | Settlement method | Roll-forward | Difference |
|---|---|---|---|
| USA | 11,110,308.00 | 241,594.30 | 10,868,713.70 |
| Canada | 1,103,090.00 CAD | 398,604.11 CAD | 704,485.89 |
| Group (USD) | **14,518,331.37** | **2,723,065.30** | 11,795,266.07 |
**Cause: every opening AR balance on this closing is zero** (`opening_mode = "zero"`). The
roll-forward is `opening + net revenue payouts received`, so with no opening balance it measures
only January's movement, not the receivable actually outstanding. The settlement figure of
14,518,331.37 is the correct one; the roll-forward — and therefore the AR Ledger and Finance
Summary tabs — is not usable on this closing until opening balances are entered.
This was previously invisible: the tabs showed both numbers with no indication that one was
unusable. **Action:** enter December 2025 closing balances as the opening, or carry forward from a
prior processed closing, then re-run the controls.
---
### A note on the aging basis (F11)
An A/R aging must measure **days past due**, not days since the transaction. An Amazon
receivable is not due when the order posts — it is due when its settlement disburses, roughly 14
days after the settlement's last activity plus the clearing lag.
This matters more than it sounds. Banding by transaction date instead would have filed
**9,556,111.11 of USA's 11,110,308 as "1-30 days overdue"** when it was simply a normal biweekly
settlement not yet due — contradicting the Finance workbook and making the report meaningless.
With the due-date basis, January 2026 is **100% Current across all 13 marketplaces**, exactly as
the manual workbook shows, while a genuinely stuck settlement now ages out of Current.
Covered by `tests/test_aging.py`.
---
## 5. No receivable value changed
Every fix was verified not to move a number. Before and after the full set of changes, on the same
source files:
| | Before | After |
|---|---|---|
| USA settlement receivable | 11,110,308 | **11,110,308** |
| Group closing receivable (USD) | 14,518,331.37 | **14,518,331.37** |
| Transaction rows | 3,399,517 | **3,399,517** |
| Σ all rows | 7,277,434.83 | **7,277,434.83** |
| Rows with NULL settlement status | 25 (4,782,085.25) | **0** |
| Reconciliation Control closing | 2,278,406.86 *(currencies mixed)* | **2,723,065.30** *(converted)* |
The two changed lines are the two defects: the payout rows are now classified, and the group total
is now converted rather than summed across currencies.
---
## 6. How to re-perform this audit
1. **Benchmark**`python -m pytest tests -m integration` (12 tests). Asserts USA January 2026 =
11,110,433 against the manual workbook and that all 13 marketplaces reconcile.
2. **Controls and equivalence**`python -m pytest tests -m "not integration"` (99 tests),
including `tests/test_controls.py`, which introduces each defect and asserts the close blocks.
3. **Parse-level re-performance** — for each source file, assert
`Σ(component amount columns) == Σ(total column)` per row, and that rows read equal the
worksheet's declared extent. Both are now controls C2 and C1.
4. **Currency check** — compare `/api/sessions/{id}/reconciliation-control` against
`/api/sessions/{id}/all-markets`. They must agree to the cent; that is control C6.
5. **Negative testing** — the controls are only worth their green light if they can go red. Each
test in `tests/test_controls.py` constructs the real defect (a half-parsed file, an unmapped
amount column, an unrecognized account type, a January rate on a July close) and asserts the
close is blocked.
### Known limitations
- **C4 is a warning, not a block.** A first-ever close legitimately has zero openings, so blocking
on it would be unusable. It must be read, not dismissed.
- **The reserve is a manual input.** USA reconciles to 11,110,433 only when the 125.44 reserve is
entered; without it the engine returns 11,110,308. No control validates the reserve against an
external source.
- **FX rates are confirmed, not sourced.** C5 proves a person accepted each rate for the reporting
month; it cannot prove the rate is right. Per the project's security constraint, no rate is ever
fetched from an external service.
- **Latent findings are fixed but unexercised by production data.** F4, F5, F6, F7, F9 and F12 are
covered by unit tests against synthetic fixtures, not by January 2026 data.
---
*Prepared as part of the A/R Aging dashboard audit. Source references: `app/core/controls.py`
(control definitions and the reasoning behind each), `app/core/reconciliation.py` (why the old
identity was not a control), `app/core/money.py` (currency-safe aggregation).*

View File

@ -125,8 +125,20 @@ flowchart TD
style I fill:#FAF0DA,stroke:#B37A1B style I fill:#FAF0DA,stroke:#B37A1B
``` ```
`clearing_lag` defaults to **2 days** and is adjustable per closing. Every payout is listed on the **Bank receipts override the heuristic.** Amazon's Transfer date is when the payout was
**Settlement Reconciliation** tab where you can override its received/in-transit status. *initiated*; the bank credit lands 3-5 working days later. On the **AR Ledger** tab Finance can
record the actual bank date (and amount) per payout — then `received ⇔ bank date ≤ month-end`,
and the Movement-by-date ledger shows the payout on its bank date. Two modes per closing:
| `payout_mode` | Payout **with** a bank receipt | Payout **without** one |
|---|---|---|
| **auto** *(default)* | bank date decides | clearing-lag heuristic on Amazon's date |
| **manual** | bank date decides | **not received** — no heuristic at all |
`clearing_lag` defaults to **2 days** and is adjustable per closing. Receipt/mode changes apply
when the closing is re-processed (a banner prompts until then). A bank amount differing from
Amazon's payout raises a `bank_amount_variance` warning; an unknown settlement id raises
`unmatched_bank_receipt`.
### Rule 2 — The receivable base ### Rule 2 — The receivable base
@ -160,15 +172,50 @@ Chosen when the closing is created (and changeable any time on the AR Ledger tab
| **Carry forward** | Copies each marketplace's closing receivable from a chosen prior closing. Falls back to zero if no processed prior exists | | **Carry forward** | Copies each marketplace's closing receivable from a chosen prior closing. Falls back to zero if no processed prior exists |
| **Manual** | Opens at 0; you type each marketplace's balance on the AR Ledger tab after processing | | **Manual** | Opens at 0; you type each marketplace's balance on the AR Ledger tab after processing |
### Rule 4 — Reconciliation identity ### Rule 4 — Bucket identity (**not** a control)
Every uploaded row lands in exactly one bucket, so this must always hold: Every uploaded row lands in exactly one bucket, so this always holds:
``` ```
uploaded_total = receivable_orders + paid_orders + transfers_total uploaded_total = receivable_orders + paid_orders + transfers_total
``` ```
Status shows **Reconciled** when the difference is within tolerance (default **$0.01**). > ⚠️ **This is a tautology and must never be read as assurance.** `uploaded_total` is accumulated
> from the same record stream that fills the three buckets, so it is one sum written twice — it
> cannot fail. It cannot detect a misclassified settlement, a wrong FX rate, an unmapped column, or
> an entire file that failed to parse (a file yielding no rows contributes zero to *both* sides).
> It reported "Reconciled" on the Jan-2026 close while the group receivable was understated by
> USD 444,658.44. It is kept only as a cheap self-consistency assert.
>
> **The month-end controls in §4a are what tell you whether a close can be trusted.**
---
## 4a. Month-end controls
Six controls run automatically at the end of every close and on demand (`app/core/controls.py`,
`services/controls_run.py`). Each compares the engine's output against **something the engine did
not produce** — that independence is what lets them fail.
| Control | Compares against | Fails when |
|---|---|---|
| **C1** Source row count | the worksheet's own `<dimension>` | rows read ≠ rows the file declares — separates "this market had no sales" from "this file failed to parse" |
| **C2** Column completeness | Amazon's own `total` column | Σ(GL lines) ≠ Σ(`total`): a column unmapped, double-mapped, or newly added by Amazon |
| **C3** Bucket completeness | the receivable filter | a money-carrying order row has no recognized account type or numeric settlement id |
| **C4** Dual-method agreement | settlement method vs roll-forward | the two closing methods diverge (**warning** — a first close with zero openings legitimately differs) |
| **C5** FX confirmed | a person, for this reporting month | a rate is a seeded default, or was confirmed for a different month |
| **C6** Currency integrity | the All-Markets roll-up | the two group totals disagree — someone added currencies without converting |
| **C7** Reader equivalence | the other parser | the two readers disagree (CI-time, `tests/test_reader_equivalence.py`) |
**Blocking.** An error-severity failure puts the closing in status `blocked`: `/summary`,
`/finance-summary`, `/aging` and both Excel exports withhold the figure and return the reason
instead. A number that cannot be trusted is never displayed — a missing number cannot be posted to
the ledger, a wrong one can.
Resolve a block on the **Controls** tab (e.g. confirm the FX rates for the month), then re-run.
Every control result travels with the workbook on its own *Month-End Controls* sheet.
See [`AUDIT-REPORT.md`](AUDIT-REPORT.md) for the audit these controls came out of.
--- ---
@ -280,7 +327,9 @@ via `cli.py`, which is what the integration tests exercise.
```mermaid ```mermaid
flowchart LR flowchart LR
OV[Overview] --> UP[Upload & Mapping] OV[Overview] --> CT[Controls]
CT --> OP[Opening Balances]
OP --> UP[Upload & Mapping]
UP --> VE[Validation & Exceptions] UP --> VE[Validation & Exceptions]
VE --> FS[Finance Summary] VE --> FS[Finance Summary]
FS --> AL[AR Ledger] FS --> AL[AR Ledger]
@ -294,17 +343,20 @@ flowchart LR
| Tab | What it shows | Key controls | | Tab | What it shows | Key controls |
|---|---|---| |---|---|---|
| **Overview** | KPI cards (closing receivable, reconciliation status, settlement counts, exceptions) and receivable by marketplace | **View details →** per marketplace; **All markets →** | | **Overview** | KPI cards (closing receivable, month-end controls verdict, settlement counts, exceptions) and receivable by marketplace | **View details →** per marketplace; **All markets →** |
| **Controls** | The six month-end controls (§4a) with pass/fail, detail and evidence; blocking state and reason | **Re-run controls** · **Confirm FX rates** for the reporting month |
| **Opening Balances** | Every marketplace's opening AR balance on one worksheet, with the roll-forward vs settlement variance each produces and the implied opening that would close it | Inline edit per market · **Carry forward all** from a prior closing · **All to zero** — saving re-runs the controls |
| **Dashboard** (home) | All closings with status and receivable | **Delete** removes the closing and every row/file it owns | | **Dashboard** (home) | All closings with status and receivable | **Delete** removes the closing and every row/file it owns |
| **Upload & Mapping** | Drag-drop upload; per-file rows, date coverage, marketplace, status | **Header mapping** panel to classify unknown columns and save the rule | | **Upload & Mapping** | Drag-drop upload; per-file rows, date coverage, marketplace, status | **Header mapping** panel to classify unknown columns and save the rule |
| **Validation & Exceptions** | Every finding grouped by severity | — | | **Validation & Exceptions** | Every finding grouped by severity | — |
| **Finance Summary** | The Finance report: opening → revenue components → gross → fees → net revenue → closing, plus the ledger | Market switcher · **All Markets** sub-tab · Summary Excel | | **Finance Summary** | The Finance report: opening → revenue components → gross → fees → net revenue → closing, plus the ledger | Market switcher · **All Markets** sub-tab · Summary Excel |
| **AR Ledger** | Opening balance (**Adjust · Carry forward · Set to zero**), roll-forward statement, ledger movement, cross-check vs settlement method, **movement by date**, **daily FX table** | Daily/Weekly/Monthly + custom range · market switcher · All Markets | | **AR Ledger** | Opening balance for the selected market (**Adjust · Carry forward · Set to zero** — the Opening Balances tab edits all markets at once), roll-forward statement, ledger movement, **bank receipts per payout** (bank date + amount; drives received/in-transit and re-dates the payout in Movement-by-date), cross-check vs settlement method, **movement by date**, **daily FX table** | Bank-dates-only mode toggle · Daily/Weekly/Monthly + custom range · market switcher · All Markets |
| **Settlement Reconciliation** | Every settlement with orders, transfers, rows, period and status; the disbursement list | **Clearing-lag** control + re-process | | **Settlement Reconciliation** | Every settlement with orders, transfers, rows, period and status; the disbursement list | **Clearing-lag** control + re-process |
| **A/R Aging** | Aging matrix (Current / 1-30 / 31-60 / 61-90 / 91-Over) and chart | — | | **A/R Aging** | Aging matrix (Current / 1-30 / 31-60 / 61-90 / 91-Over) and chart | — |
| **Transaction Details** | Virtualized grid of every row with source file + row | Filters: settlement, type, marketplace, receivable, storage, search | | **Transaction Details** | Virtualized grid of every row with source file + row | Filters: settlement, type, marketplace, receivable, storage, search |
| **Reconciliation** | Sign-explained metrics (Revenue / Fees / Memo / Settlements), closing & variance, plus the **Reconciliation Control** | Finance amounts, tolerance, **Verify**, **Mark month complete** | | **Reconciliation** | Sign-explained metrics (Revenue / Fees / Memo / Settlements), closing & variance, plus the **Reconciliation Control** | Finance amounts, tolerance, **Verify**, **Mark month complete** |
| **Journal Entry** | GL decomposition per period with account names | Journal Entry # | | **Journal Entry** | The month-end **accrual** entry in double-entry form (Debit/Credit columns, Σ Dr = Σ Cr): revenue & fees per GL account with **Advertising Cost** broken out, balanced by Dr A/R = net revenue. No Transfer line — bank receipts post separately. Per-marketplace GL names, market switcher + All Markets (USD) view | Journal Entry # · **Reviewed by / Approved by** two-step sign-off — approval publishes the month to the Accounts Summary; re-processing withdraws it |
| **Accounts Summary** *(sidebar)* | Approved journal entries across **all months × marketplaces** — per-market local currency or All Markets converted at each closing's confirmed FX | Market selector · links back to each closing |
| **Excel Export** | Generate/download Summary or Full workbook, with history | Two generate buttons | | **Excel Export** | Generate/download Summary or Full workbook, with history | Two generate buttons |
### The All Markets sub-tab ### The All Markets sub-tab

View File

@ -1,6 +1,7 @@
import { NavLink, Route, Routes } from "react-router-dom"; import { NavLink, Route, Routes } from "react-router-dom";
import { LayoutDashboard, FilePlus2, Settings as SettingsIcon, Landmark } from "lucide-react"; import { LayoutDashboard, FilePlus2, Settings as SettingsIcon, Landmark, Table2 } from "lucide-react";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import AccountsSummary from "./pages/AccountsSummary";
import NewClosing from "./pages/NewClosing"; import NewClosing from "./pages/NewClosing";
import Closing from "./pages/Closing"; import Closing from "./pages/Closing";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
@ -42,6 +43,7 @@ export default function App() {
</div> </div>
<nav className="px-3 py-2 space-y-1 flex-1"> <nav className="px-3 py-2 space-y-1 flex-1">
<SideLink to="/" icon={LayoutDashboard} end>Dashboard</SideLink> <SideLink to="/" icon={LayoutDashboard} end>Dashboard</SideLink>
<SideLink to="/accounts" icon={Table2}>Accounts Summary</SideLink>
<SideLink to="/new" icon={FilePlus2}>New Closing</SideLink> <SideLink to="/new" icon={FilePlus2}>New Closing</SideLink>
<SideLink to="/settings" icon={SettingsIcon}>Settings</SideLink> <SideLink to="/settings" icon={SettingsIcon}>Settings</SideLink>
</nav> </nav>
@ -53,6 +55,7 @@ export default function App() {
<main className="flex-1 min-w-0 overflow-y-auto"> <main className="flex-1 min-w-0 overflow-y-auto">
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/accounts" element={<AccountsSummary />} />
<Route path="/new" element={<NewClosing />} /> <Route path="/new" element={<NewClosing />} />
<Route path="/closing/:id/*" element={<Closing />} /> <Route path="/closing/:id/*" element={<Closing />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />

View File

@ -42,6 +42,59 @@ export interface SessionT {
error: string; error: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
/** A month-end control failed with error severity — no receivable figure is published. */
blocked?: boolean;
blocked_reason?: string;
/** auto = bank-receipt date wins, clearing-lag fallback · manual = bank dates only. */
payout_mode?: string;
/** Bank receipts / payout mode changed after the last run — re-process to apply. */
needs_reprocess?: boolean;
}
export interface PayoutT {
marketplace: string;
account_type: string;
settlement_id: string;
amazon_date: string | null;
amount: number;
rows: number;
bank_date: string | null;
bank_amount: number | null;
note: string;
entered_by: string;
received_now: boolean | null;
received_next_run: boolean;
}
export interface PayoutsT {
payout_mode: string;
clearing_lag_days: number;
month_end: string | null;
needs_reprocess: boolean;
payouts: PayoutT[];
}
/** One month-end control (core/controls.py). Distinct from ControlRowT, which is a row of
* the Finance reconciliation control sheet. */
export interface MonthEndControlT {
key: string;
label: string;
status: "pass" | "fail" | "not_applicable";
severity: "error" | "warning" | "info";
detail: string;
evidence: string[];
checked_at: string | null;
}
export interface ControlsT {
available: boolean;
controls: MonthEndControlT[];
blocked: boolean;
blocked_reason: string;
passed: number;
failed: number;
total: number;
confirmed?: number;
} }
export interface FileT { export interface FileT {
@ -60,7 +113,18 @@ export interface FileT {
worksheets: string[]; worksheets: string[];
} }
export interface SummaryT { /**
* Endpoints that publish a receivable figure return this instead when a month-end control
* has failed: `available:false, blocked:true` and NO numbers. Every consumer must check
* `blocked` before reading a figure the fields below are absent in that case.
*/
export interface BlockableT {
blocked?: boolean;
blocked_reason?: string;
available?: boolean;
}
export interface SummaryT extends BlockableT {
closing_receivable_usd: number | null; closing_receivable_usd: number | null;
reconciliation_status: string | null; reconciliation_status: string | null;
reserve_total: number; reserve_total: number;
@ -154,9 +218,31 @@ export interface JournalT {
available: boolean; available: boolean;
entry_no?: string; entry_no?: string;
marketplace?: string; marketplace?: string;
marketplaces?: string[];
periods?: { key: string; label: string; min_date: string | null; max_date: string | null }[]; periods?: { key: string; label: string; min_date: string | null; max_date: string | null }[];
lines?: JournalLineT[]; lines?: JournalLineT[];
receivable?: JournalLineT; receivable?: JournalLineT;
/** Balancing figure of the accrual entry (Transfer excluded): Dr A/R by net revenue. */
receivable_accrual?: JournalLineT;
reviewed_by?: string;
reviewed_at?: string | null;
approved_by?: string;
approved_at?: string | null;
}
export interface AccountsSummaryT {
available: boolean;
line_keys: string[];
receivable_key: string;
months: {
month: string; session_id: number; session_name: string;
reviewed_by: string; approved_by: string; approved_at: string | null; entry_no: string;
}[];
marketplaces: string[];
cells: {
month: string; session_id: number; marketplace: string; currency: string; fx_rate: number;
values: Record<string, number>; receivable: number;
}[];
} }
export interface ComponentT { export interface ComponentT {
@ -166,7 +252,7 @@ export interface ComponentT {
values: number[]; values: number[];
total: number; total: number;
} }
export interface FinanceSummaryT { export interface FinanceSummaryT extends BlockableT {
available: boolean; available: boolean;
marketplace?: string; marketplace?: string;
marketplaces?: string[]; marketplaces?: string[];
@ -292,6 +378,41 @@ export interface OpeningBalanceT {
reason: string; reason: string;
source: string; source: string;
} }
export interface DefinitionT {
formula: string;
source: string;
note?: string;
}
export interface OpeningWorksheetRowT {
marketplace: string;
currency: string;
fx_rate: number;
opening: number;
source: string;
reason: string;
net_revenue: number;
payouts_received: number;
movement: number;
roll_forward_closing: number;
settlement_closing: number | null;
variance: number | null;
implied_opening: number | null;
reconciled: boolean;
}
export interface OpeningWorksheetT {
available: boolean;
reporting_month: string;
mode: string;
source_session_id: number | null;
rows: OpeningWorksheetRowT[];
all_zero: boolean;
unreconciled: number;
total_abs_variance_usd: number;
candidates: OpeningCandidateT[];
}
export interface LedgerRowT { export interface LedgerRowT {
period: string; period: string;
description: string; description: string;
@ -370,11 +491,22 @@ export const api = {
req<MappingRulesT>(`/mapping-rules/${ruleId}`, { method: "DELETE" }), req<MappingRulesT>(`/mapping-rules/${ruleId}`, { method: "DELETE" }),
reconciliation: (id: number) => req<ReconT>(`/sessions/${id}/reconciliation`), reconciliation: (id: number) => req<ReconT>(`/sessions/${id}/reconciliation`),
aging: (id: number) => aging: (id: number) =>
req<{ bands: string[]; rows: Record<string, number | string>[] }>(`/sessions/${id}/aging`), req<BlockableT & { bands: string[]; rows: Record<string, number | string>[] }>(
journal: (id: number) => req<JournalT>(`/sessions/${id}/journal`), `/sessions/${id}/aging`),
journal: (id: number, marketplace?: string) =>
req<JournalT>(`/sessions/${id}/journal${marketplace ? `?marketplace=${encodeURIComponent(marketplace)}` : ""}`),
reviewJournal: (id: number, name: string) =>
req<JournalT>(`/sessions/${id}/journal/review`, { method: "POST", body: JSON.stringify({ name }) }),
approveJournal: (id: number, name: string) =>
req<JournalT>(`/sessions/${id}/journal/approve`, { method: "POST", body: JSON.stringify({ name }) }),
resetJournalSignoff: (id: number) =>
req<JournalT>(`/sessions/${id}/journal/reset-signoff`, { method: "POST" }),
accountsSummary: () => req<AccountsSummaryT>("/accounts-summary"),
setJournalEntryNo: (id: number, entry_no: string) => setJournalEntryNo: (id: number, entry_no: string) =>
req(`/sessions/${id}/journal/entry-no`, { method: "PUT", body: JSON.stringify({ entry_no }) }), req(`/sessions/${id}/journal/entry-no`, { method: "PUT", body: JSON.stringify({ entry_no }) }),
openings: (id: number) => req<OpeningBalanceT[]>(`/sessions/${id}/opening-balances`), openings: (id: number) => req<OpeningBalanceT[]>(`/sessions/${id}/opening-balances`),
openingWorksheet: (id: number) =>
req<OpeningWorksheetT>(`/sessions/${id}/opening-balances/worksheet`),
putOpenings: (id: number, items: OpeningBalanceT[]) => putOpenings: (id: number, items: OpeningBalanceT[]) =>
req<OpeningBalanceT[]>(`/sessions/${id}/opening-balances`, { method: "PUT", body: JSON.stringify(items) }), req<OpeningBalanceT[]>(`/sessions/${id}/opening-balances`, { method: "PUT", body: JSON.stringify(items) }),
openingCandidates: (id: number) => openingCandidates: (id: number) =>
@ -405,6 +537,27 @@ export const api = {
}), }),
completeSession: (id: number) => req<{ status: string }>(`/sessions/${id}/complete`, { method: "POST" }), completeSession: (id: number) => req<{ status: string }>(`/sessions/${id}/complete`, { method: "POST" }),
/** Formula + source for every dashboard figure — content of the (i) info buttons. */
definitions: () => req<Record<string, DefinitionT>>("/definitions"),
payouts: (id: number, marketplace?: string) =>
req<PayoutsT>(`/sessions/${id}/payouts${marketplace ? `?marketplace=${encodeURIComponent(marketplace)}` : ""}`),
putPayoutReceipts: (id: number, items: {
marketplace: string; account_type: string; settlement_id: string;
bank_date: string | null; bank_amount?: number | null; note?: string; entered_by?: string;
}[]) => req<{ saved: number; removed: number; needs_reprocess: boolean }>(
`/sessions/${id}/payouts/receipts`, { method: "PUT", body: JSON.stringify(items) }),
putPayoutMode: (id: number, mode: "auto" | "manual") =>
req<{ payout_mode: string; needs_reprocess: boolean }>(
`/sessions/${id}/payouts/mode`, { method: "PUT", body: JSON.stringify({ mode }) }),
controls: (id: number) => req<ControlsT>(`/sessions/${id}/controls`),
runControls: (id: number) => req<ControlsT>(`/sessions/${id}/controls/run`, { method: "POST" }),
confirmAllFx: (id: number, confirmed_by: string) =>
req<ControlsT>(`/sessions/${id}/fx/confirm-all`, {
method: "POST", body: JSON.stringify({ confirmed_by }),
}),
getReserves: (id: number) => getReserves: (id: number) =>
req<{ marketplace: string; account_type: string; amount: number }[]>(`/sessions/${id}/reserves`), req<{ marketplace: string; account_type: string; amount: number }[]>(`/sessions/${id}/reserves`),
putReserves: (id: number, items: { marketplace: string; account_type: string; amount: number }[]) => putReserves: (id: number, items: { marketplace: string; account_type: string; amount: number }[]) =>

View File

@ -0,0 +1,165 @@
import { useEffect, 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 { acct, date as fmtDate } from "../lib/format";
import { InfoTip, Section, Spinner, useDefinitions } from "./ui";
/**
* Bank receipts when each Amazon payout actually reached the bank.
*
* Amazon's Transfer row is dated when the payout was INITIATED; the money lands 3-5
* working days later. The bank date entered here (not Amazon's date) decides received vs
* in-transit received bank date month-end and places the payout on its real day
* in the Movement-by-date ledger. Classification changes apply on re-process.
*/
export default function BankReceipts({ id, marketplace }: { id: number; marketplace?: string }) {
const qc = useQueryClient();
const defs = useDefinitions();
const { data, isLoading } = useQuery({
queryKey: ["payouts", id, marketplace ?? ""],
queryFn: () => api.payouts(id, marketplace),
});
// drafts: `${acct}|${sid}` -> {bank_date, bank_amount} as input text. Only touched rows save.
const [drafts, setDrafts] = useState<Record<string, { d: string; a: string }>>({});
useEffect(() => setDrafts({}), [marketplace, data?.payouts?.length]);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["payouts", id] });
qc.invalidateQueries({ queryKey: ["session", id] });
qc.invalidateQueries({ queryKey: ["ledger-detail", id] });
};
const save = useMutation({
mutationFn: () => {
const items = (data?.payouts ?? [])
.filter((p) => key(p) in drafts)
.map((p) => {
const d = drafts[key(p)];
return {
marketplace: p.marketplace, account_type: p.account_type,
settlement_id: p.settlement_id,
bank_date: d.d.trim() || null,
bank_amount: d.a.trim() === "" ? null : Number(d.a),
};
});
return api.putPayoutReceipts(id, items);
},
onSuccess: () => { setDrafts({}); invalidate(); },
});
const setMode = useMutation({
mutationFn: (mode: "auto" | "manual") => api.putPayoutMode(id, mode),
onSuccess: invalidate,
});
const reprocess = useMutation({
mutationFn: () => api.process(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["session", id] }),
});
if (isLoading) return null;
if (!data?.payouts?.length) return null;
const manual = data.payout_mode === "manual";
const dirty = Object.keys(drafts).length > 0;
return (
<Section
title="Bank receipts — when each payout reached the bank"
subtitle={`Amazon's date is when the payout was initiated; the bank credit lands 3-5 working days later. ${
manual ? "Manual mode: a payout without a bank date is NOT received."
: `Auto mode: without a bank date the ${data.clearing_lag_days}-day clearing-lag fallback applies.`}`}
actions={
<div className="flex items-center gap-2">
<InfoTip def={defs.bank_receipt} label="Bank receipts" />
<label className="flex items-center gap-1.5 text-xs text-subink cursor-pointer select-none">
<input type="checkbox" checked={manual} disabled={setMode.isPending}
onChange={(e) => setMode.mutate(e.target.checked ? "manual" : "auto")} />
Bank dates only (no clearing-lag)
</label>
</div>
}
>
{data.needs_reprocess && (
<div className="mx-4 mt-3 rounded-lg border border-warn/30 bg-warnbg/40 px-3 py-2 flex flex-wrap items-center gap-3 text-sm">
<Clock size={15} className="text-warn shrink-0" />
<span className="flex-1 min-w-[240px]">
Receipt entries changed <b>re-process the closing</b> to apply them to the
receivable and the ledger. The dates below already preview the effect.
</span>
<button className="btn-ghost" disabled={reprocess.isPending}
onClick={() => reprocess.mutate()}>
{reprocess.isPending ? <Spinner /> : <RefreshCw size={14} />} Re-process now
</button>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full">
<thead><tr>
{!marketplace && <th className="th">Marketplace</th>}
<th className="th">Settlement</th>
<th className="th">Stream</th>
<th className="th">Amazon date</th>
<th className="th text-right">Amazon amount</th>
<th className="th">Bank received date</th>
<th className="th text-right">Bank amount (optional)</th>
<th className="th">Status</th>
</tr></thead>
<tbody>
{data.payouts.map((p) => {
const k = key(p);
const d = drafts[k];
const bankDate = d ? d.d : (p.bank_date ?? "");
const bankAmt = d ? d.a : (p.bank_amount != null ? String(p.bank_amount) : "");
const willReceive = d !== undefined
? (bankDate ? (!data.month_end || bankDate <= data.month_end) : (manual ? false : p.received_next_run))
: p.received_next_run;
return (
<tr key={k} className={d ? "bg-primary-soft/30" : ""}>
{!marketplace && <td className="td font-medium">{p.marketplace}</td>}
<td className="td num text-xs">{p.settlement_id}</td>
<td className="td text-xs text-subink">
{p.account_type === "(unspecified)" ? "—" : p.account_type}</td>
<td className="td num">{fmtDate(p.amazon_date)}</td>
<td className="td text-right num">{acct(p.amount)}</td>
<td className="td">
<input type="date" className="input py-1 text-sm" value={bankDate}
max={undefined}
onChange={(e) => setDrafts((s) => ({ ...s, [k]: { d: e.target.value, a: bankAmt } }))} />
</td>
<td className="td text-right">
<input className="input num py-1 w-32 text-right" placeholder={acct(p.amount)}
value={bankAmt}
onChange={(e) => setDrafts((s) => ({ ...s, [k]: { d: bankDate, a: e.target.value } }))} />
</td>
<td className="td">
<span className={`badge ${willReceive ? "bg-okbg text-ok" : "bg-warnbg text-warn"}`}>
{willReceive ? <CheckCircle2 size={12} /> : <Clock size={12} />}
{willReceive ? "received" : "in transit"}
</span>
{bankDate && <span className="text-[10px] text-subink ml-1.5">bank-dated</span>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{dirty && (
<div className="border-t border-line p-3 flex items-center gap-3 bg-canvas/40">
<Banknote size={15} className="text-primary" />
<span className="text-sm text-subink flex-1">
{Object.keys(drafts).length} payout(s) edited saving marks the closing for re-processing.
</span>
<button className="btn-ghost" onClick={() => setDrafts({})}>Discard</button>
<button className="btn-primary" disabled={save.isPending} onClick={() => save.mutate()}>
<Save size={15} /> {save.isPending ? "Saving…" : "Save bank receipts"}
</button>
{save.isError && <span className="text-sm text-bad">{(save.error as Error).message}</span>}
</div>
)}
</Section>
);
}
const key = (p: PayoutT) => `${p.account_type}|${p.settlement_id}|${p.marketplace}`;

View File

@ -1,5 +1,17 @@
import { ReactNode, useCallback, useEffect, useState } from "react"; import { ReactNode, useCallback, useEffect, useState } from "react";
import { Loader2, UploadCloud, CheckCircle2, AlertTriangle, XCircle, Info } from "lucide-react"; import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Loader2, UploadCloud, CheckCircle2, AlertTriangle, XCircle, Info, ShieldAlert } from "lucide-react";
import { api, DefinitionT } from "../api/client";
/** All figure definitions, fetched once and cached for the session. */
export function useDefinitions(): Record<string, DefinitionT> {
const { data } = useQuery({
queryKey: ["definitions"], queryFn: api.definitions,
staleTime: Infinity, gcTime: Infinity,
});
return data ?? {};
}
export function Spinner({ className = "" }: { className?: string }) { export function Spinner({ className = "" }: { className?: string }) {
return <Loader2 className={`animate-spin ${className}`} size={16} />; return <Loader2 className={`animate-spin ${className}`} size={16} />;
@ -23,7 +35,7 @@ export function Section({ title, actions, children, subtitle }: {
} }
export function Kpi({ label, value, sub, tone = "default", mono = true }: { export function Kpi({ label, value, sub, tone = "default", mono = true }: {
label: string; value: ReactNode; sub?: ReactNode; label: ReactNode; value: ReactNode; sub?: ReactNode;
tone?: "default" | "primary" | "ok" | "warn" | "bad"; mono?: boolean; tone?: "default" | "primary" | "ok" | "warn" | "bad"; mono?: boolean;
}) { }) {
const toneClass = { const toneClass = {
@ -74,6 +86,94 @@ export function StatusBadge({ status }: { status: string | null | undefined }) {
); );
} }
/**
* (i) info button explains where a figure comes from.
*
* Content is served by /api/definitions from `backend/app/core/definitions.py`, which sits
* next to the engine code it documents, so these popovers cannot drift from what the engine
* actually computes. Click to toggle; Escape or clicking elsewhere closes.
*/
export function InfoTip({ def, label }: { def?: DefinitionT; label?: string }) {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
// Delay so the opening click doesn't immediately close it.
const t = setTimeout(() => window.addEventListener("click", close), 0);
window.addEventListener("keydown", onKey);
return () => { clearTimeout(t); window.removeEventListener("click", close);
window.removeEventListener("keydown", onKey); };
}, [open]);
if (!def) return null;
return (
<span className="relative inline-flex align-middle">
<button
type="button"
aria-label={`How ${label ?? "this figure"} is calculated`}
className={`ml-1 inline-flex items-center justify-center w-5 h-5 rounded-full
${open ? "text-primary" : "text-faint hover:text-primary"} transition-colors`}
// No stopPropagation: the click must reach other InfoTips' window listeners so any
// already-open popover closes. Self-closing on the opening click is impossible
// anyway — this popover's own listener attaches after the event (setTimeout 0).
onClick={() => setOpen((v) => !v)}>
<Info size={13} />
</button>
{open && (
<span
// normal-case/tracking-normal/font-normal: KPI labels are uppercase-tracked, and
// this popover renders inside them — reset so definitions read as prose.
className="absolute z-40 left-5 top-0 w-80 max-w-[80vw] card shadow-pop p-3 text-left
cursor-auto normal-case tracking-normal font-normal"
onClick={(e) => e.stopPropagation()}>
{label && <span className="block text-xs font-semibold text-ink mb-1.5">{label}</span>}
<span className="block text-xs num text-primary bg-primary-soft/60 rounded px-2 py-1 mb-2 whitespace-pre-wrap">
{def.formula}
</span>
<span className="block text-xs text-subink whitespace-pre-wrap">
<b className="text-ink">From:</b> {def.source}
</span>
{def.note && (
<span className="block text-xs text-subink mt-1.5 whitespace-pre-wrap">
<b className="text-ink">Note:</b> {def.note}
</span>
)}
</span>
)}
</span>
);
}
/**
* Shown instead of a figure when a month-end control has failed.
*
* Any tab that reads a receivable number must render this rather than the number: a missing
* number cannot be posted to the ledger, a wrong one can. Endpoints signal it with
* `{available: false, blocked: true, blocked_reason}` treat a missing figure as blocked
* rather than rendering `undefined`.
*/
export function BlockedNotice({ reason }: { reason?: string }) {
// The failed control is already named in the banner at the top of every closing tab, so
// this only has to explain why THIS tab is empty — repeating the reason twice on one
// screen reads as two separate problems.
return (
<div className="card border-bad/30 bg-badbg/25 p-5 flex items-start gap-3">
<ShieldAlert size={18} className="text-bad shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-ink">Figures withheld</p>
<p className="text-sm text-subink mt-1">
A month-end control failed, so no receivable number is published for this closing.
{reason ? " See the banner above for which one." : ""} Resolve it on the{" "}
<Link to="../controls" relative="path" className="text-primary font-medium hover:underline">
Controls tab
</Link>{" "}
and re-run.
</p>
</div>
</div>
);
}
export function EmptyState({ title, hint, action }: { title: string; hint?: string; action?: ReactNode }) { export function EmptyState({ title, hint, action }: { title: string; hint?: string; action?: ReactNode }) {
return ( return (
<div className="text-center py-14 px-6"> <div className="text-center py-14 px-6">

View File

@ -0,0 +1,151 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BadgeCheck, Table2 } from "lucide-react";
import { api } from "../api/client";
import { acct, money } from "../lib/format";
import { EmptyState, Section, Spinner } from "../components/ui";
const ALL = "__all__";
/**
* Accounts Summary approved journal entries across every month and marketplace.
*
* Rows are the journal's GL lines (accrual presentation: no Transfer, Receivable = net
* revenue Dr A/R); columns are the approved months. Per-marketplace views show local
* currency; All Markets converts each marketplace at its closing's confirmed FX rate.
* A month appears here only after its journal is approved on the Journal Entry tab.
*/
export default function AccountsSummary() {
const { data, isLoading } = useQuery({
queryKey: ["accounts-summary"], queryFn: api.accountsSummary,
});
const [mkt, setMkt] = useState(ALL);
if (isLoading)
return <div className="p-6 flex items-center gap-2 text-subink"><Spinner /> Loading</div>;
if (!data?.available)
return (
<div className="p-6 max-w-7xl mx-auto">
<Header />
<EmptyState title="No approved months yet"
hint="Open a closing's Journal Entry tab, have it reviewed and approved — approval publishes that month here, for every marketplace." />
</div>
);
const months = data.months;
const rowKeys = [...data.line_keys, data.receivable_key];
const showAll = mkt === ALL;
const cell = (month: string, marketplace: string) =>
data.cells.find((c) => c.month === month && c.marketplace === marketplace);
const value = (month: string, key: string): number => {
if (!showAll) {
const c = cell(month, mkt);
if (!c) return 0;
return key === data.receivable_key ? c.receivable : (c.values[key] ?? 0);
}
return data.cells
.filter((c) => c.month === month)
.reduce((s, c) => s + (key === data.receivable_key
? c.receivable : (c.values[key] ?? 0)) * (c.fx_rate || 1), 0);
};
const currency = showAll ? "USD" : (cell(months[0]?.month, mkt)?.currency ?? "");
return (
<div className="p-6 max-w-7xl mx-auto space-y-6">
<Header />
<div className="flex items-center gap-1.5 flex-wrap">
{[ALL, ...data.marketplaces].map((m) => (
<button key={m}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
mkt === m ? "bg-primary text-white" : "bg-neutralbg text-subink hover:text-ink"}`}
onClick={() => setMkt(m)}>
{m === ALL ? "All Markets (USD)" : m}
</button>
))}
</div>
<Section
title={showAll ? "All marketplaces — converted to USD" : `Amazon ${mkt}${currency}`}
subtitle="Approved journal entries only. Each column is one month's accrual entry; Receivable is the debit to A/R (net revenue).">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr>
<th className="th">Line</th>
{months.map((m) => (
<th key={m.month} className="th text-right">
{m.month}
<span className="block text-[10px] font-normal text-subink normal-case">
{m.entry_no ? `JE ${m.entry_no}` : m.session_name}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{rowKeys.map((k) => {
const isRec = k === data.receivable_key;
return (
<tr key={k} className={isRec ? "bg-primary-soft/50 font-semibold" : ""}>
<td className={`td font-medium ${isRec ? "text-primary" : ""}`}>
{isRec ? "Receivable (Dr A/R)" : k}
</td>
{months.map((m) => {
// Engine convention: credits +, debits . The Receivable row is labelled
// "Dr A/R", so show the debit as a positive figure rather than red-negative.
const v = value(m.month, k) * (isRec ? -1 : 1);
return (
<td key={m.month}
className={`td text-right num ${isRec ? "text-primary" : v < 0 ? "text-bad" : ""}`}>
{v ? acct(v) : "0.00"}
</td>
);
})}
</tr>
);
})}
<tr className="bg-neutralbg/50">
<td className="td text-xs font-semibold text-subink">Approved by</td>
{months.map((m) => (
<td key={m.month} className="td text-right text-xs text-subink">
<span className="inline-flex items-center gap-1">
<BadgeCheck size={12} className="text-ok" /> {m.approved_by}
</span>
<Link to={`/closing/${m.session_id}/journal`}
className="block text-primary hover:underline">open closing </Link>
</td>
))}
</tr>
</tbody>
</table>
</div>
</Section>
<p className="text-xs text-subink">
{showAll
? "USD figures convert each marketplace at its own closing's confirmed FX rate."
: `Figures are in ${currency || "local currency"} exactly as booked for Amazon ${mkt}.`}{" "}
Withdrawing a sign-off, re-processing, or a failed month-end control removes a month
from this view until it is approved again.
</p>
</div>
);
}
function Header() {
return (
<header>
<h1 className="text-xl font-semibold text-ink flex items-center gap-2">
<Table2 size={20} className="text-primary" /> Accounts Summary
</h1>
<p className="text-sm text-subink">
Approved month-end journal entries every month and marketplace side by side.
</p>
</header>
);
}

View File

@ -1,9 +1,12 @@
import { NavLink, Outlet, Route, Routes, useParams, useOutletContext } from "react-router-dom"; import { NavLink, Outlet, Route, Routes, useParams, useOutletContext } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { Clock, RefreshCw, ShieldAlert } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, SessionT } from "../api/client"; import { api, SessionT } from "../api/client";
import { StatusBadge, ProgressStages, Spinner } from "../components/ui"; import { StatusBadge, ProgressStages, Spinner } from "../components/ui";
import { date } from "../lib/format"; import { date } from "../lib/format";
import Overview from "./closing/Overview"; import Overview from "./closing/Overview";
import Controls from "./closing/Controls";
import OpeningBalances from "./closing/OpeningBalances";
import Upload from "./closing/Upload"; import Upload from "./closing/Upload";
import Exceptions from "./closing/Exceptions"; import Exceptions from "./closing/Exceptions";
import Settlements from "./closing/Settlements"; import Settlements from "./closing/Settlements";
@ -19,12 +22,27 @@ export interface ClosingCtx { id: number; session: SessionT; processed: boolean
export const useClosing = () => useOutletContext<ClosingCtx>(); export const useClosing = () => useOutletContext<ClosingCtx>();
const TABS = [ const TABS = [
["", "Overview"], ["upload", "Upload & Mapping"], ["exceptions", "Validation & Exceptions"], ["", "Overview"], ["controls", "Controls"], ["upload", "Upload & Mapping"],
["exceptions", "Validation & Exceptions"],
["opening", "Opening Balances"],
["summary", "Finance Summary"], ["ledger", "AR Ledger"], ["settlements", "Settlement Reconciliation"], ["aging", "A/R Aging"], ["summary", "Finance Summary"], ["ledger", "AR Ledger"], ["settlements", "Settlement Reconciliation"], ["aging", "A/R Aging"],
["transactions", "Transaction Details"], ["reconciliation", "Reconciliation"], ["transactions", "Transaction Details"], ["reconciliation", "Reconciliation"],
["journal", "Journal Entry"], ["export", "Excel Export"], ["journal", "Journal Entry"], ["export", "Excel Export"],
] as const; ] as const;
function ReprocessButton({ id }: { id: number }) {
const qc = useQueryClient();
const run = useMutation({
mutationFn: () => api.process(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["session", id] }),
});
return (
<button className="btn-ghost shrink-0" disabled={run.isPending} onClick={() => run.mutate()}>
{run.isPending ? <Spinner /> : <RefreshCw size={14} />} Re-process now
</button>
);
}
export default function Closing() { export default function Closing() {
const { id } = useParams(); const { id } = useParams();
const sid = Number(id); const sid = Number(id);
@ -40,7 +58,10 @@ export default function Closing() {
if (isLoading || !session) if (isLoading || !session)
return <div className="p-6 flex items-center gap-2 text-subink"><Spinner /> Loading closing</div>; return <div className="p-6 flex items-center gap-2 text-subink"><Spinner /> Loading closing</div>;
const processed = session.status === "processed" || session.status === "completed"; // A blocked closing is fully processed — its tabs stay open for diagnosis, but every
// endpoint that publishes a receivable figure withholds it until the control is resolved.
const processed = session.status === "processed" || session.status === "completed"
|| session.status === "blocked";
return ( return (
<div> <div>
@ -80,11 +101,39 @@ export default function Closing() {
<div className="card border-bad/40 bg-badbg/40 p-4 text-sm text-bad whitespace-pre-wrap">{session.error}</div> <div className="card border-bad/40 bg-badbg/40 p-4 text-sm text-bad whitespace-pre-wrap">{session.error}</div>
</div> </div>
)} )}
{session.needs_reprocess && session.status !== "processing" && (
<div className="px-6 pt-4">
<div className="card border-warn/30 bg-warnbg/40 p-3 flex items-center gap-3 text-sm">
<Clock size={16} className="text-warn shrink-0" />
<span className="flex-1">
Bank receipts or the payout mode changed after the last run the figures on
screen don't reflect them yet. <b>Re-process to apply.</b>
</span>
<ReprocessButton id={sid} />
</div>
</div>
)}
{session.blocked && (
<div className="px-6 pt-4">
<div className="card border-bad/40 bg-badbg/40 p-4 flex items-start gap-3">
<ShieldAlert size={20} className="text-bad shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-bad">
Blocked by a month-end control no receivable figure is published
</p>
<p className="text-xs text-subink mt-1 break-words">{session.blocked_reason}</p>
</div>
<NavLink to="controls" className="btn-ghost shrink-0">Open Controls</NavLink>
</div>
</div>
)}
<div className="p-6"> <div className="p-6">
<Routes> <Routes>
<Route element={<Outlet context={{ id: sid, session, processed } satisfies ClosingCtx} />}> <Route element={<Outlet context={{ id: sid, session, processed } satisfies ClosingCtx} />}>
<Route index element={<Overview />} /> <Route index element={<Overview />} />
<Route path="controls" element={<Controls />} />
<Route path="opening" element={<OpeningBalances />} />
<Route path="upload" element={<Upload />} /> <Route path="upload" element={<Upload />} />
<Route path="exceptions" element={<Exceptions />} /> <Route path="exceptions" element={<Exceptions />} />
<Route path="settlements" element={<Settlements />} /> <Route path="settlements" element={<Settlements />} />

View File

@ -108,5 +108,8 @@ export default function Dashboard() {
function LatestReceivable({ id }: { id: number }) { function LatestReceivable({ id }: { id: number }) {
const { data } = useQuery({ queryKey: ["summary", id], queryFn: () => api.summary(id) }); const { data } = useQuery({ queryKey: ["summary", id], queryFn: () => api.summary(id) });
// Blocked closings publish no figure — say so rather than showing a dash that reads as zero.
if (data?.blocked)
return <span className="text-bad text-xs font-medium" title={data.blocked_reason}>blocked</span>;
return <span>{usd(data?.closing_receivable_usd)}</span>; return <span>{usd(data?.closing_receivable_usd)}</span>;
} }

View File

@ -2,22 +2,25 @@ import { useQuery } from "@tanstack/react-query";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { api } from "../../api/client"; import { api } from "../../api/client";
import { usd } from "../../lib/format"; import { usd } from "../../lib/format";
import { Section, EmptyState } from "../../components/ui"; import { BlockedNotice, InfoTip, Section, EmptyState, useDefinitions } from "../../components/ui";
import { useClosing } from "../Closing"; import { useClosing } from "../Closing";
export default function Aging() { export default function Aging() {
const { id, processed } = useClosing(); const { id, processed } = useClosing();
const defs = useDefinitions();
const { data } = useQuery({ queryKey: ["aging", id], queryFn: () => api.aging(id), enabled: processed }); const { data } = useQuery({ queryKey: ["aging", id], queryFn: () => api.aging(id), enabled: processed });
if (!processed) return <EmptyState title="Process the closing to see the A/R aging." />; if (!processed) return <EmptyState title="Process the closing to see the A/R aging." />;
if (!data) return null; if (data?.blocked) return <BlockedNotice reason={data.blocked_reason} />;
if (!data?.rows) return null;
const chart = data.rows.map((r) => ({ name: String(r.marketplace), Current: Number(r.Current) || 0 })); const chart = data.rows.map((r) => ({ name: String(r.marketplace), Current: Number(r.Current) || 0 }));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Section title="Accounts Receivable Aging" <Section title="Accounts Receivable Aging"
subtitle="Amazon settles ~biweekly, so month-end receivable is classified as Current."> subtitle="Banded by days past due at month-end — a settlement is due 14 days after its last activity plus the clearing lag."
actions={<InfoTip def={defs.aging_basis} label="How the aging bands work" />}>
<div className="overflow-auto"> <div className="overflow-auto">
<table className="w-full"> <table className="w-full">
<thead><tr> <thead><tr>

View File

@ -1,11 +1,12 @@
import { useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { ArrowDownRight, ArrowUpRight, Pencil, Save, CalendarRange, import { ArrowDownRight, ArrowUpRight, Pencil, Save, CalendarRange,
RotateCcw, CornerDownRight } from "lucide-react"; RotateCcw, CornerDownRight } from "lucide-react";
import { api, OpeningBalanceT } from "../../api/client"; import { api, OpeningBalanceT } from "../../api/client";
import { money, acct, num } from "../../lib/format"; import { money, acct, num } from "../../lib/format";
import { Section, EmptyState, StatusBadge } from "../../components/ui"; import { InfoTip, Section, EmptyState, StatusBadge, useDefinitions } from "../../components/ui";
import { AllMarketsTable, MarketTabs, isAll, useMarket, ALL_MARKETS } from "../../components/market"; import { AllMarketsTable, MarketTabs, isAll, useMarket, ALL_MARKETS } from "../../components/market";
import BankReceipts from "../../components/BankReceipts";
import { useClosing } from "../Closing"; import { useClosing } from "../Closing";
type Gran = "day" | "week" | "month"; type Gran = "day" | "week" | "month";
@ -13,6 +14,7 @@ type Gran = "day" | "week" | "month";
export default function ArLedger() { export default function ArLedger() {
const { id, processed } = useClosing(); const { id, processed } = useClosing();
const qc = useQueryClient(); const qc = useQueryClient();
const defs = useDefinitions();
const [sel, setSel] = useMarket(); const [sel, setSel] = useMarket();
const showAll = isAll(sel); const showAll = isAll(sel);
const mktParam = showAll ? undefined : sel; const mktParam = showAll ? undefined : sel;
@ -82,13 +84,17 @@ export default function ArLedger() {
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Section title="Closing receivable" subtitle="Opening + Net Revenue Payouts received"> <Section title="Closing receivable" subtitle="Opening + Net Revenue Payouts received"
actions={<InfoTip def={defs.closing_receivable} label="Closing receivable (roll-forward)" />}>
<div className="p-4 space-y-2 text-sm"> <div className="p-4 space-y-2 text-sm">
<Row label="Opening AR balance" v={mv.opening!} cur={cur} /> <Row label={<>Opening AR balance<InfoTip def={defs.opening_balance} label="Opening AR balance" /></>}
<Row label="+ Net revenue (accrued)" v={mv.net_revenue!} cur={cur} pos /> v={mv.opening!} cur={cur} />
<Row label={<>+ Net revenue (accrued)<InfoTip def={defs.net_revenue} label="Net revenue" /></>}
v={mv.net_revenue!} cur={cur} pos />
<div className="border-t border-line my-1" /> <div className="border-t border-line my-1" />
<Row label="= Total Amazon receivable" v={mv.total_receivable!} cur={cur} bold /> <Row label="= Total Amazon receivable" v={mv.total_receivable!} cur={cur} bold />
<Row label=" Amazon payouts received" v={mv.received_payouts!} cur={cur} /> <Row label={<> Amazon payouts received<InfoTip def={defs.disbursements} label="Payouts received" /></>}
v={mv.received_payouts!} cur={cur} />
<div className="border-t border-line my-1" /> <div className="border-t border-line my-1" />
<div className="flex items-center justify-between pt-1"> <div className="flex items-center justify-between pt-1">
<span className="font-semibold text-primary">= Closing receivable</span> <span className="font-semibold text-primary">= Closing receivable</span>
@ -97,6 +103,7 @@ export default function ArLedger() {
<p className="text-xs text-subink pt-2"> <p className="text-xs text-subink pt-2">
In-transit payouts of <span className="num">{m(mv.in_transit_payouts)}</span> remain In-transit payouts of <span className="num">{m(mv.in_transit_payouts)}</span> remain
in receivable (not yet cleared). in receivable (not yet cleared).
<InfoTip def={defs.in_transit_payouts} label="In-transit payouts" />
</p> </p>
</div> </div>
</Section> </Section>
@ -138,6 +145,10 @@ export default function ArLedger() {
</div> </div>
</div> </div>
{/* Bank receipts: when each payout actually reached the bank (drives received
vs in-transit and the Movement-by-date placement below). */}
<BankReceipts id={id} marketplace={mkt} />
<div className={`card p-4 flex flex-wrap items-center gap-4 ${reconciled ? "bg-okbg/40" : "bg-warnbg/40"}`}> <div className={`card p-4 flex flex-wrap items-center gap-4 ${reconciled ? "bg-okbg/40" : "bg-warnbg/40"}`}>
<StatusBadge status={reconciled ? "reconciled" : "review"} /> <StatusBadge status={reconciled ? "reconciled" : "review"} />
<div className="text-sm"> <div className="text-sm">
@ -288,7 +299,7 @@ export default function ArLedger() {
} }
function Row({ label, v, cur, bold, pos }: { function Row({ label, v, cur, bold, pos }: {
label: string; v: number; cur: string; bold?: boolean; pos?: boolean; label: ReactNode; v: number; cur: string; bold?: boolean; pos?: boolean;
}) { }) {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -0,0 +1,136 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, XCircle, MinusCircle, AlertTriangle, RefreshCw, ShieldCheck } from "lucide-react";
import { api, MonthEndControlT } from "../../api/client";
import { Section, EmptyState, Spinner } from "../../components/ui";
import { useClosing } from "../Closing";
const ICON = {
pass: { Icon: CheckCircle2, cls: "text-ok", bg: "bg-okbg/40", border: "border-ok/30" },
fail: { Icon: XCircle, cls: "text-bad", bg: "bg-badbg/40", border: "border-bad/40" },
not_applicable: { Icon: MinusCircle, cls: "text-subink", bg: "bg-neutralbg/50", border: "border-line" },
} as const;
/** A failing warning is amber, not red — only error severity blocks the close. */
function tone(r: MonthEndControlT) {
if (r.status === "fail" && r.severity !== "error")
return { Icon: AlertTriangle, cls: "text-warn", bg: "bg-warnbg/40", border: "border-warn/30" };
return ICON[r.status] ?? ICON.not_applicable;
}
export default function Controls() {
const { id, session } = useClosing();
const qc = useQueryClient();
const [who, setWho] = useState("");
const { data, isLoading } = useQuery({
queryKey: ["controls", id], queryFn: () => api.controls(id),
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["controls", id] });
qc.invalidateQueries({ queryKey: ["session", id] });
qc.invalidateQueries({ queryKey: ["summary", id] });
qc.invalidateQueries({ queryKey: ["sessions"] });
};
const rerun = useMutation({ mutationFn: () => api.runControls(id), onSuccess: invalidate });
const confirmFx = useMutation({
mutationFn: () => api.confirmAllFx(id, who.trim()), onSuccess: invalidate,
});
if (isLoading) return <div className="p-6 flex items-center gap-2 text-subink"><Spinner /> Loading controls</div>;
if (!data?.available)
return <EmptyState title="No controls have run yet."
hint="Process the closing — the month-end controls run automatically at the end of processing." />;
const fxFailing = data.controls.some((r) => r.key === "C5" && r.status === "fail");
return (
<div className="space-y-6">
<div className={`card p-4 flex flex-wrap items-center gap-4 ${
data.blocked ? "bg-badbg/40 border-bad/40" : "bg-okbg/40 border-ok/30"}`}>
<span className={data.blocked ? "text-bad" : "text-ok"}>
{data.blocked ? <XCircle size={22} /> : <ShieldCheck size={22} />}
</span>
<div className="flex-1 min-w-[240px]">
<div className="text-sm font-semibold text-ink">
{data.blocked
? "This closing is blocked — no receivable figure is published"
: data.failed > 0
? `${data.passed} of ${data.total} controls passed — ${data.failed} needs review`
: `All month-end controls passed (${data.passed}/${data.total})`}
</div>
<p className="text-xs text-subink mt-0.5">
{data.blocked
? "A number that cannot be trusted is never shown. Resolve the failed control below, then re-run."
: data.failed > 0
? "Nothing is blocking the close, but the items marked review below should be understood before signing off."
: "Every figure on this closing has been checked against the source files, not against itself."}
</p>
</div>
<button className="btn-ghost" disabled={rerun.isPending} onClick={() => rerun.mutate()}>
{rerun.isPending ? <Spinner /> : <RefreshCw size={15} />} Re-run controls
</button>
</div>
{fxFailing && (
<Section title="Confirm exchange rates"
subtitle={`Control C5 requires a rate confirmed for ${session.reporting_month ?? "this month"}. Seeded defaults are a January-2026 snapshot and are treated as missing.`}>
<div className="p-4 flex flex-wrap items-end gap-3">
<label className="text-sm">
<span className="block text-xs font-medium text-subink mb-1">Confirmed by</span>
<input className="input" placeholder="Your name" value={who}
onChange={(e) => setWho(e.target.value)} />
</label>
<button className="btn-primary" disabled={!who.trim() || confirmFx.isPending}
onClick={() => confirmFx.mutate()}>
{confirmFx.isPending ? <Spinner /> : <CheckCircle2 size={15} />}
Confirm all rates for {session.reporting_month ?? "this month"}
</button>
<p className="text-xs text-subink flex-1 min-w-[220px]">
Review the rates on the Settings tab first confirming records who accepted them and when.
</p>
</div>
{confirmFx.isError && (
<p className="px-4 pb-4 text-sm text-bad">{(confirmFx.error as Error).message}</p>
)}
</Section>
)}
<Section title="Month-end controls"
subtitle="Each control compares the engine's output against something the engine did not produce.">
<ul className="divide-y divide-line">
{data.controls.map((r) => {
const { Icon, cls, bg, border } = tone(r);
return (
<li key={r.key} className={`p-4 ${r.status === "fail" ? bg : ""}`}>
<div className="flex items-start gap-3">
<span className={`mt-0.5 shrink-0 ${cls}`}><Icon size={18} /></span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="num text-xs font-semibold text-subink">{r.key}</span>
<span className="text-sm font-medium text-ink">{r.label}</span>
{r.status === "fail" && (
<span className={`badge ${r.severity === "error" ? "bg-badbg text-bad" : "bg-warnbg text-warn"}`}>
{r.severity === "error" ? "blocking" : "review"}
</span>
)}
</div>
<p className="text-sm text-subink mt-1">{r.detail}</p>
{r.evidence.length > 0 && (
<ul className={`mt-2 rounded-lg border ${border} bg-panel/60 divide-y divide-line text-xs`}>
{r.evidence.map((e, i) => (
<li key={i} className="px-3 py-1.5 num text-subink break-words">{e}</li>
))}
</ul>
)}
</div>
</div>
</li>
);
})}
</ul>
</Section>
</div>
);
}

View File

@ -2,7 +2,7 @@ import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { Download, FileSpreadsheet } from "lucide-react"; import { Download, FileSpreadsheet } from "lucide-react";
import { api, ComponentT } from "../../api/client"; import { api, ComponentT } from "../../api/client";
import { money, acct } from "../../lib/format"; import { money, acct } from "../../lib/format";
import { Section, EmptyState, StatusBadge } from "../../components/ui"; import { BlockedNotice, InfoTip, Section, EmptyState, StatusBadge, useDefinitions } from "../../components/ui";
import { AllMarketsTable, MarketTabs, isAll, useMarket, ALL_MARKETS } from "../../components/market"; import { AllMarketsTable, MarketTabs, isAll, useMarket, ALL_MARKETS } from "../../components/market";
import { useClosing } from "../Closing"; import { useClosing } from "../Closing";
@ -13,6 +13,7 @@ function Amt({ v, bold }: { v: number | null | undefined; bold?: boolean }) {
export default function FinanceSummary() { export default function FinanceSummary() {
const { id, processed } = useClosing(); const { id, processed } = useClosing();
const defs = useDefinitions();
const [sel, setSel] = useMarket(); const [sel, setSel] = useMarket();
const showAll = isAll(sel); const showAll = isAll(sel);
const mkt = showAll ? undefined : sel; const mkt = showAll ? undefined : sel;
@ -27,6 +28,7 @@ export default function FinanceSummary() {
}); });
if (!processed) return <EmptyState title="Process the closing to generate the Finance summary." />; if (!processed) return <EmptyState title="Process the closing to generate the Finance summary." />;
if (data?.blocked) return <BlockedNotice reason={data.blocked_reason} />;
if (!data?.available) return <EmptyState title="Finance summary not available yet." />; if (!data?.available) return <EmptyState title="Finance summary not available yet." />;
const cur = data.currency ?? "USD"; const cur = data.currency ?? "USD";
@ -39,14 +41,14 @@ export default function FinanceSummary() {
const Row = ({ c }: { c: ComponentT }) => ( const Row = ({ c }: { c: ComponentT }) => (
<tr> <tr>
<td className="td">{c.label}</td> <td className="td">{c.label}<InfoTip def={defs[c.key]} label={c.label} /></td>
{c.values.map((v, i) => <td key={i} className="td text-right"><Amt v={v} /></td>)} {c.values.map((v, i) => <td key={i} className="td text-right"><Amt v={v} /></td>)}
<td className="td text-right border-l border-line"><Amt v={c.total} bold /></td> <td className="td text-right border-l border-line"><Amt v={c.total} bold /></td>
</tr> </tr>
); );
const SubTotal = ({ label, v }: { label: string; v: number | undefined }) => ( const SubTotal = ({ label, defKey, v }: { label: string; defKey: string; v: number | undefined }) => (
<tr className="bg-primary-soft/50"> <tr className="bg-primary-soft/50">
<td className="td font-semibold text-primary">{label}</td> <td className="td font-semibold text-primary">{label}<InfoTip def={defs[defKey]} label={label} /></td>
{periods.map((_, i) => <td key={i} className="td" />)} {periods.map((_, i) => <td key={i} className="td" />)}
<td className="td text-right border-l border-line num font-semibold text-primary">{m(v, 2)}</td> <td className="td text-right border-l border-line num font-semibold text-primary">{m(v, 2)}</td>
</tr> </tr>
@ -93,14 +95,15 @@ export default function FinanceSummary() {
</tr></thead> </tr></thead>
<tbody> <tbody>
<tr className="bg-neutralbg/50"> <tr className="bg-neutralbg/50">
<td className="td font-semibold">Opening AR balance</td> <td className="td font-semibold">Opening AR balance
<InfoTip def={defs.opening_balance} label="Opening AR balance" /></td>
{periods.map((_, i) => <td key={i} className="td" />)} {periods.map((_, i) => <td key={i} className="td" />)}
<td className="td text-right border-l border-line num font-semibold">{m(data.opening_balance, 2)}</td> <td className="td text-right border-l border-line num font-semibold">{m(data.opening_balance, 2)}</td>
</tr> </tr>
{rev.map((c) => <Row key={c.key} c={c} />)} {rev.map((c) => <Row key={c.key} c={c} />)}
<SubTotal label="Gross revenue" v={data.gross_revenue} /> <SubTotal label="Gross revenue" defKey="gross_revenue" v={data.gross_revenue} />
{fees.map((c) => <Row key={c.key} c={c} />)} {fees.map((c) => <Row key={c.key} c={c} />)}
<SubTotal label="Net revenue" v={data.net_revenue} /> <SubTotal label="Net revenue" defKey="net_revenue" v={data.net_revenue} />
{memo.length > 0 && ( {memo.length > 0 && (
<tr><td className="td pt-4 text-xs font-semibold text-muted uppercase tracking-wide" <tr><td className="td pt-4 text-xs font-semibold text-muted uppercase tracking-wide"
colSpan={periods.length + 2}>Memo already included above</td></tr> colSpan={periods.length + 2}>Memo already included above</td></tr>
@ -114,23 +117,27 @@ export default function FinanceSummary() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<Section title="Closing receivable calculation"> <Section title="Closing receivable calculation">
<div className="p-4 space-y-2 text-sm"> <div className="p-4 space-y-2 text-sm">
{[ {([
["Opening AR balance", data.opening_balance], ["Opening AR balance", "opening_balance", data.opening_balance],
["+ Net revenue", data.net_revenue], ["+ Net revenue", "net_revenue", data.net_revenue],
["= Total Amazon receivable", (data.opening_balance ?? 0) + (data.net_revenue ?? 0)], ["= Total Amazon receivable", "", (data.opening_balance ?? 0) + (data.net_revenue ?? 0)],
[" Amazon payouts received", data.disbursements], [" Amazon payouts received", "disbursements", data.disbursements],
].map(([label, v]) => ( ] as [string, string, number | undefined][]).map(([label, defKey, v]) => (
<div key={label as string} className="flex justify-between"> <div key={label} className="flex justify-between">
<span className={String(label).startsWith("=") ? "font-semibold" : "text-subink"}>{label}</span> <span className={label.startsWith("=") ? "font-semibold" : "text-subink"}>
{label}{defKey && <InfoTip def={defs[defKey]} label={label.replace(/^[+=] /, "")} />}
</span>
<span className={`num ${(v as number) < 0 ? "text-bad" : ""}`}>{m(v as number, 2)}</span> <span className={`num ${(v as number) < 0 ? "text-bad" : ""}`}>{m(v as number, 2)}</span>
</div> </div>
))} ))}
<div className="border-t border-line pt-2 flex justify-between"> <div className="border-t border-line pt-2 flex justify-between">
<span className="font-semibold text-primary">= Closing receivable</span> <span className="font-semibold text-primary">= Closing receivable
<InfoTip def={defs.closing_receivable} label="Closing receivable" /></span>
<span className="num text-lg font-semibold text-primary">{m(data.closing_receivable)}</span> <span className="num text-lg font-semibold text-primary">{m(data.closing_receivable)}</span>
</div> </div>
<p className="text-xs text-subink pt-1"> <p className="text-xs text-subink pt-1">
In-transit payouts <span className="num">{m(data.in_transit_payouts)}</span> stay in receivable. In-transit payouts <span className="num">{m(data.in_transit_payouts)}</span> stay in receivable.
<InfoTip def={defs.in_transit_payouts} label="In-transit payouts" />
</p> </p>
</div> </div>
</Section> </Section>

View File

@ -1,23 +1,50 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api/client"; import { BadgeCheck, CheckCircle2, FileCheck2, RotateCcw } from "lucide-react";
import { acct } from "../../lib/format"; import { api, JournalLineT, JournalT } from "../../api/client";
import { Section, EmptyState } from "../../components/ui"; import { acct, date as fmtDate } from "../../lib/format";
import { EmptyState, InfoTip, Section, Spinner, useDefinitions } from "../../components/ui";
import { ALL_MARKETS, MarketTabs, isAll, useMarket } from "../../components/market";
import { useClosing } from "../Closing"; import { useClosing } from "../Closing";
function Amt({ v, bold }: { v: number; bold?: boolean }) { /**
const neg = v < 0; * Month-end ACCRUAL journal entry, in proper double-entry form.
return ( *
<span className={`num ${bold ? "font-semibold" : ""} ${neg ? "text-bad" : "text-ink"}`}> * The Transfer (bank clearing) line is deliberately not part of this entry: bank receipts
{acct(v)} * are posted separately from bank statements. The entry books the month's revenue & fees
</span> * with Accounts Receivable as the balancing figure (= net revenue accrued), so
); * Σ Debits = Σ Credits always.
*
* Sign convention from the engine: positive line total = Credit, negative = Debit.
*/
const dr = (v: number) => (v < 0 ? -v : 0);
const cr = (v: number) => (v > 0 ? v : 0);
/** Display set: every GL line except Transfer, plus the accrual Receivable. */
function displayLines(j: JournalT): { lines: JournalLineT[]; rec: JournalLineT | null } {
const lines = (j.lines ?? []).filter((ln) => ln.key !== "Transfer");
const rec = j.receivable_accrual
?? (lines.length
? { key: "Receivable", gl_account: j.receivable?.gl_account ?? "Accounts Receivable",
values: [], total: -Math.round(lines.reduce((s, ln) => s + ln.total, 0) * 100) / 100 }
: null);
return { lines, rec };
} }
export default function JournalEntry() { export default function JournalEntry() {
const { id, processed } = useClosing(); const { id, processed } = useClosing();
const qc = useQueryClient(); const qc = useQueryClient();
const { data } = useQuery({ queryKey: ["journal", id], queryFn: () => api.journal(id), enabled: processed }); const defs = useDefinitions();
const [sel, setSel] = useMarket();
const showAll = isAll(sel);
const mkt = showAll ? undefined : sel;
const { data } = useQuery({
queryKey: ["journal", id, mkt ?? ""],
queryFn: () => api.journal(id, mkt),
enabled: processed,
});
const [entryNo, setEntryNo] = useState(""); const [entryNo, setEntryNo] = useState("");
useEffect(() => { if (data?.entry_no !== undefined) setEntryNo(data.entry_no ?? ""); }, [data?.entry_no]); useEffect(() => { if (data?.entry_no !== undefined) setEntryNo(data.entry_no ?? ""); }, [data?.entry_no]);
const saveNo = useMutation({ const saveNo = useMutation({
@ -30,16 +57,17 @@ export default function JournalEntry() {
return <EmptyState title="Journal entry not available" return <EmptyState title="Journal entry not available"
hint="It is built during processing — re-run the closing if this persists." />; hint="It is built during processing — re-run the closing if this persists." />;
const periods = data.periods ?? []; const markets = data.marketplaces ?? [data.marketplace ?? "USA"];
const lines = data.lines ?? []; const multi = markets.length > 1;
const rec = data.receivable!;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="card p-4 flex flex-wrap items-center gap-x-6 gap-y-3"> <div className="card p-4 flex flex-wrap items-center gap-x-6 gap-y-3">
<div> <div>
<div className="text-xs font-semibold text-muted uppercase tracking-wide">Journal Entry</div> <div className="text-xs font-semibold text-muted uppercase tracking-wide">Journal Entry</div>
<div className="text-lg font-semibold text-ink">Amazon {data.marketplace} month-end</div> <div className="text-lg font-semibold text-ink">
{showAll ? "All marketplaces — month-end" : `Amazon ${data.marketplace} — month-end`}
</div>
</div> </div>
<div> <div>
<label className="label mb-1">Journal Entry #</label> <label className="label mb-1">Journal Entry #</label>
@ -48,48 +76,237 @@ export default function JournalEntry() {
onBlur={() => entryNo !== (data.entry_no ?? "") && saveNo.mutate(entryNo)} /> onBlur={() => entryNo !== (data.entry_no ?? "") && saveNo.mutate(entryNo)} />
</div> </div>
<p className="text-xs text-subink flex-1 min-w-[220px]"> <p className="text-xs text-subink flex-1 min-w-[220px]">
Every Amazon transaction column is booked to a GL account per 10day period; the The month-end <b>accrual</b> entry: revenue &amp; fees per GL account, balanced by a
<b> Receivable</b> is the balancing figure (net of all lines) and equals the amount posted to A/R. debit to Accounts Receivable (= net revenue). Bank receipts are posted separately
from bank statements, so there is no Transfer line here.
<InfoTip def={defs.journal_entry} label="How this entry is built" />
</p> </p>
{multi && <MarketTabs markets={markets} value={showAll ? ALL_MARKETS : (data.marketplace ?? "")}
onChange={setSel} />}
</div> </div>
<Section title="Amazon USA — Journal Entry decomposition" {showAll
subtitle="Computed from the raw transaction columns; reconciles to the closing receivable."> ? <AllMarketsJournal id={id} markets={markets} />
: <SingleMarketJournal j={data} />}
<SignOff id={id} j={data} />
</div>
);
}
/* ------------------------------------------------ one marketplace, Dr/Cr layout */
function SingleMarketJournal({ j }: { j: JournalT }) {
const defs = useDefinitions();
const periods = j.periods ?? [];
const { lines, rec } = displayLines(j);
const all = rec ? [...lines, rec] : lines;
const totDr = all.reduce((s, ln) => s + dr(ln.total), 0);
const totCr = all.reduce((s, ln) => s + cr(ln.total), 0);
const showPeriods = periods.length > 1;
return (
<Section title={`Amazon ${j.marketplace} — journal entry decomposition`}
subtitle="Computed from the raw transaction columns. Debits equal credits by construction.">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead> <thead><tr>
<tr> <th className="th">Line</th>
<th className="th">{periods[0]?.label?.split(" ").slice(-2).join(" ") ?? "Line"}</th> {showPeriods && periods.map((p) => <th key={p.key} className="th text-right">{p.label}</th>)}
{periods.map((p) => <th key={p.key} className="th text-right">{p.label}</th>)} <th className="th text-right border-l border-line">Debit</th>
<th className="th text-right">Total</th> <th className="th text-right">Credit</th>
<th className="th">GL Account</th> <th className="th">GL Account</th>
</tr> </tr></thead>
</thead>
<tbody> <tbody>
{lines.map((ln) => ( {lines.map((ln) => <Row key={ln.key} ln={ln} showPeriods={showPeriods}
<tr key={ln.key}> nPeriods={periods.length} def={defs[ln.key]} />)}
<td className="td font-medium">{ln.key}</td> {rec && <Row ln={rec} showPeriods={showPeriods} nPeriods={periods.length}
{ln.values.map((v, i) => <td key={i} className="td text-right"><Amt v={v} /></td>)} def={defs.Receivable} highlight />}
<td className="td text-right border-l border-line"><Amt v={ln.total} bold /></td> <tr className="bg-neutralbg/60 font-semibold">
<td className="td text-xs text-subink">{ln.gl_account}</td> <td className="td">Totals</td>
</tr> {showPeriods && periods.map((p) => <td key={p.key} className="td" />)}
))} <td className="td text-right num border-l border-line">{acct(totDr)}</td>
<tr className="bg-primary-soft/50"> <td className="td text-right num">{acct(totCr)}</td>
<td className="td font-semibold text-primary">Receivable</td> <td className="td text-xs text-subink">
{rec.values.map((v, i) => <td key={i} className="td text-right"><Amt v={v} bold /></td>)} {Math.abs(totDr - totCr) < 0.02
<td className="td text-right border-l border-line"><Amt v={rec.total} bold /></td> ? <span className="inline-flex items-center gap-1 text-ok">
<td className="td text-xs text-subink">{rec.gl_account}</td> <CheckCircle2 size={13} /> balanced</span>
: <span className="text-bad num">out of balance {acct(totDr - totCr)}</span>}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</Section> </Section>
);
<p className="text-xs text-subink"> }
Verified against the manual sheet to the penny: Sales, Refunds, Tax, FBA Selling Fee, FBA Storage,
FBA Fee, Freight, Transfer and Receivable all match. FBA Fee is the catchall Amazon fee/adjustment function Row({ ln, showPeriods, nPeriods, def, highlight }: {
bucket (fba fees + promotional rebates + other transaction fees + nonstorage/shipping adjustments). ln: JournalLineT; showPeriods: boolean; nPeriods: number;
</p> def?: { formula: string; source: string; note?: string }; highlight?: boolean;
</div> }) {
return (
<tr className={highlight ? "bg-primary-soft/50" : ""}>
<td className={`td font-medium ${highlight ? "text-primary" : ""}`}>
{ln.key}<InfoTip def={def} label={ln.key} />
</td>
{showPeriods && Array.from({ length: nPeriods }, (_, i) => (
<td key={i} className="td text-right num text-subink">
{ln.values[i] != null ? acct(ln.values[i]) : ""}
</td>
))}
<td className="td text-right num border-l border-line">
{dr(ln.total) ? acct(dr(ln.total)) : ""}
</td>
<td className="td text-right num">{cr(ln.total) ? acct(cr(ln.total)) : ""}</td>
<td className="td text-xs text-subink">{ln.gl_account}</td>
</tr>
);
}
/* -------------------------------------------- every marketplace side by side */
function AllMarketsJournal({ id, markets }: { id: number; markets: string[] }) {
const journals = useQueries({
queries: markets.map((m) => ({
queryKey: ["journal", id, m],
queryFn: () => api.journal(id, m),
})),
});
const { data: fx } = useQuery({ queryKey: ["fx", id], queryFn: () => api.getFx(id) });
if (journals.some((q) => q.isLoading))
return <div className="p-6 flex items-center gap-2 text-subink"><Spinner /> Loading</div>;
const rate = (m: string) => fx?.find((r) => r.marketplace === m)?.rate ?? 1;
const cur = (m: string) => fx?.find((r) => r.marketplace === m)?.currency ?? "";
const per: Record<string, { lines: JournalLineT[]; rec: JournalLineT | null }> = {};
journals.forEach((q, i) => { if (q.data?.available) per[markets[i]] = displayLines(q.data); });
const first = markets.find((m) => per[m]);
const keys = first ? per[first].lines.map((l) => l.key) : [];
const local = (m: string, key: string) =>
per[m]?.lines.find((l) => l.key === key)?.total ?? 0;
const recOf = (m: string) => per[m]?.rec?.total ?? 0;
const usdTotal = (key: string) =>
markets.reduce((s, m) => s + local(m, key) * rate(m), 0);
return (
<Section title="All marketplaces — journal entry"
subtitle="Each marketplace posts its own entry in local currency; the USD column converts at the closing's confirmed rates.">
<div className="overflow-x-auto">
<table className="w-full">
<thead><tr>
<th className="th">Line</th>
{markets.map((m) => <th key={m} className="th text-right">{m}
<span className="block text-[10px] font-normal text-subink">{cur(m)}</span></th>)}
<th className="th text-right border-l border-line">Total (USD)</th>
</tr></thead>
<tbody>
{keys.map((k) => (
<tr key={k}>
<td className="td font-medium">{k}</td>
{markets.map((m) => <td key={m} className="td text-right num">
{per[m] ? acct(local(m, k)) : "—"}</td>)}
<td className="td text-right num font-medium border-l border-line">{acct(usdTotal(k))}</td>
</tr>
))}
<tr className="bg-primary-soft/50 font-semibold">
<td className="td text-primary">Receivable (Dr A/R)</td>
{markets.map((m) => <td key={m} className="td text-right num">
{per[m] ? acct(recOf(m)) : "—"}</td>)}
<td className="td text-right num border-l border-line text-primary">
{acct(markets.reduce((s, m) => s + recOf(m) * rate(m), 0))}
</td>
</tr>
</tbody>
</table>
</div>
</Section>
);
}
/* ------------------------------------------------------- review & approval */
function SignOff({ id, j }: { id: number; j: JournalT }) {
const qc = useQueryClient();
const [reviewer, setReviewer] = useState("");
const [approver, setApprover] = useState("");
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["journal", id] });
qc.invalidateQueries({ queryKey: ["accounts-summary"] });
};
const review = useMutation({
mutationFn: () => api.reviewJournal(id, reviewer.trim()),
onSuccess: () => { setReviewer(""); invalidate(); },
});
const approve = useMutation({
mutationFn: () => api.approveJournal(id, approver.trim()),
onSuccess: () => { setApprover(""); invalidate(); },
});
const reset = useMutation({ mutationFn: () => api.resetJournalSignoff(id), onSuccess: invalidate });
const reviewed = !!j.reviewed_by;
const approved = !!j.approved_by;
return (
<Section title="Review & approval"
subtitle="Approval publishes this month — every marketplace — to the Accounts Summary. Re-processing the closing withdraws the sign-off automatically.">
<div className="p-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className={`rounded-xl border p-4 ${reviewed ? "border-ok/30 bg-okbg/30" : "border-line"}`}>
<div className="flex items-center gap-2 text-sm font-semibold text-ink">
<FileCheck2 size={16} className={reviewed ? "text-ok" : "text-subink"} />
1 · Reviewed by
</div>
{reviewed ? (
<p className="text-sm mt-2">
<b>{j.reviewed_by}</b>
<span className="text-xs text-subink ml-2">{fmtDate(j.reviewed_at)}</span>
</p>
) : (
<div className="flex gap-2 mt-2">
<input className="input flex-1" placeholder="Reviewer's name" value={reviewer}
onChange={(e) => setReviewer(e.target.value)} />
<button className="btn-primary" disabled={!reviewer.trim() || review.isPending}
onClick={() => review.mutate()}>
{review.isPending ? <Spinner /> : <FileCheck2 size={15} />} Mark reviewed
</button>
</div>
)}
{review.isError && <p className="text-sm text-bad mt-2">{(review.error as Error).message}</p>}
</div>
<div className={`rounded-xl border p-4 ${approved ? "border-ok/30 bg-okbg/30" : "border-line"}`}>
<div className="flex items-center gap-2 text-sm font-semibold text-ink">
<BadgeCheck size={16} className={approved ? "text-ok" : "text-subink"} />
2 · Approved by
</div>
{approved ? (
<p className="text-sm mt-2">
<b>{j.approved_by}</b>
<span className="text-xs text-subink ml-2">{fmtDate(j.approved_at)}</span>
<span className="badge bg-okbg text-ok ml-2"><CheckCircle2 size={12} /> published to Accounts Summary</span>
</p>
) : (
<div className="flex gap-2 mt-2">
<input className="input flex-1" placeholder="Approver's name" value={approver}
onChange={(e) => setApprover(e.target.value)}
disabled={!reviewed} />
<button className="btn-primary" disabled={!reviewed || !approver.trim() || approve.isPending}
onClick={() => approve.mutate()}>
{approve.isPending ? <Spinner /> : <BadgeCheck size={15} />} Approve
</button>
</div>
)}
{!reviewed && !approved &&
<p className="text-xs text-subink mt-2">Requires a review first.</p>}
{approve.isError && <p className="text-sm text-bad mt-2">{(approve.error as Error).message}</p>}
</div>
</div>
{(reviewed || approved) && (
<div className="px-4 pb-4">
<button className="btn-ghost text-xs" disabled={reset.isPending} onClick={() => reset.mutate()}>
<RotateCcw size={13} /> Withdraw sign-off
{approved ? " (removes this month from the Accounts Summary)" : ""}
</button>
</div>
)}
</Section>
); );
} }

View File

@ -0,0 +1,211 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, CornerDownRight, Info, RotateCcw, Save } from "lucide-react";
import { api, OpeningWorksheetRowT } from "../../api/client";
import { acct, money } from "../../lib/format";
import { EmptyState, Section, Spinner, StatusBadge } from "../../components/ui";
import { useClosing } from "../Closing";
/**
* Opening AR balances every marketplace on one screen.
*
* The roll-forward closing is `opening + net revenue payouts received`, checked against the
* settlement method per marketplace. The opening is the only Finance-supplied input in that
* equation, so this worksheet shows, for each marketplace, the opening currently in effect
* and the variance it produces and lets all of them be entered at once instead of
* switching through 13 market tabs on the AR Ledger.
*
* Carry-forward pulls every marketplace's closing from a prior processed month in one step;
* a closing created in carry-forward mode has already had this applied automatically.
*/
export default function OpeningBalances() {
const { id, processed } = useClosing();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["opening-worksheet", id],
queryFn: () => api.openingWorksheet(id),
enabled: processed,
});
// Draft edits: marketplace -> input text. Only touched rows are saved.
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [reason, setReason] = useState("");
const [srcId, setSrcId] = useState<number | undefined>();
useEffect(() => setDrafts({}), [data?.rows?.length]);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["opening-worksheet", id] });
qc.invalidateQueries({ queryKey: ["openings", id] });
qc.invalidateQueries({ queryKey: ["ar-movement", id] });
qc.invalidateQueries({ queryKey: ["ledger-detail", id] });
qc.invalidateQueries({ queryKey: ["controls", id] });
qc.invalidateQueries({ queryKey: ["session", id] });
qc.invalidateQueries({ queryKey: ["finance-summary", id] });
qc.invalidateQueries({ queryKey: ["all-markets", id] });
};
const save = useMutation({
mutationFn: () =>
api.putOpenings(
id,
Object.entries(drafts)
.filter(([, v]) => v.trim() !== "")
.map(([marketplace, v]) => ({
marketplace,
amount: Number(v) || 0,
reason: reason || "Entered on the Opening Balances worksheet",
source: "manual",
})),
),
onSuccess: () => { setDrafts({}); setReason(""); invalidate(); },
});
const carry = useMutation({
mutationFn: () => api.carryForwardOpenings(id, srcId),
onSuccess: invalidate,
});
const reset = useMutation({ mutationFn: () => api.resetOpenings(id), onSuccess: invalidate });
if (!processed) return <EmptyState title="Process the closing to manage opening balances." />;
if (isLoading) return <div className="p-6 flex items-center gap-2 text-subink"><Spinner /> Loading</div>;
if (!data?.available) return <EmptyState title="Opening balances not available yet." />;
const dirty = Object.values(drafts).some((v) => v.trim() !== "");
const priors = data.candidates ?? [];
return (
<div className="space-y-6">
{data.all_zero && (
<div className="card border-warn/30 bg-warnbg/40 p-4 flex items-start gap-3">
<Info size={18} className="text-warn shrink-0 mt-0.5" />
<div className="text-sm">
<b>Every opening balance is zero.</b>{" "}
<span className="text-subink">
The roll-forward closing (and the AR Ledger / Finance Summary built on it) is
measuring only this month's movement the settlement figure is the reliable one
until openings are entered. Carry them forward from the prior closing, or type
last month's closing balance per marketplace below.
</span>
</div>
</div>
)}
<Section
title="Opening AR balances — all marketplaces"
subtitle={`Roll-forward closing = opening + net revenue payouts received, checked against the settlement method. ${
data.mode === "carry_forward" ? "Openings were carried forward automatically when this closing was created." : ""
}`}
actions={
<div className="flex items-center gap-2">
{priors.length > 0 && (
<>
<select className="input py-1.5 text-sm max-w-[240px]"
value={srcId ?? priors[0]?.session_id ?? ""}
onChange={(e) => setSrcId(Number(e.target.value))}>
{priors.map((p) => (
<option key={p.session_id} value={p.session_id}>
{p.name}{p.reporting_month ? ` · ${p.reporting_month}` : ""}
</option>
))}
</select>
<button className="btn-ghost" disabled={carry.isPending} onClick={() => carry.mutate()}>
<CornerDownRight size={15} /> {carry.isPending ? "Carrying…" : "Carry forward all"}
</button>
</>
)}
<button className="btn-ghost" disabled={reset.isPending} onClick={() => reset.mutate()}>
<RotateCcw size={15} /> All to zero
</button>
</div>
}
>
<div className="overflow-x-auto">
<table className="w-full">
<thead><tr>
<th className="th">Marketplace</th>
<th className="th text-right">Opening AR balance</th>
<th className="th">Source</th>
<th className="th text-right">+ Net revenue</th>
<th className="th text-right"> Payouts received</th>
<th className="th text-right">= Roll-forward</th>
<th className="th text-right">Settlement</th>
<th className="th text-right">Variance</th>
<th className="th"></th>
</tr></thead>
<tbody>
{data.rows.map((r) => <Row key={r.marketplace} r={r}
draft={drafts[r.marketplace] ?? ""}
onDraft={(v) => setDrafts((d) => ({ ...d, [r.marketplace]: v }))} />)}
</tbody>
</table>
</div>
{dirty && (
<div className="border-t border-line p-4 flex flex-wrap items-end gap-3 bg-canvas/40">
<label className="text-sm flex-1 min-w-[260px]">
<span className="block text-xs font-medium text-subink mb-1">
Reason (applies to every edited marketplace)
</span>
<input className="input w-full" value={reason} onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Opening balances from the December 2025 closing workbook" />
</label>
<button className="btn-ghost" onClick={() => { setDrafts({}); setReason(""); }}>Discard</button>
<button className="btn-primary" disabled={save.isPending} onClick={() => save.mutate()}>
<Save size={15} /> {save.isPending ? "Saving…" : `Save ${Object.values(drafts).filter((v) => v.trim() !== "").length} opening balance(s)`}
</button>
{save.isError && <p className="text-sm text-bad w-full">{(save.error as Error).message}</p>}
</div>
)}
</Section>
<p className="text-xs text-subink px-1">
A variance with openings entered means the opening, a reserve, or a month-boundary
payout needs review see control C4 on the Controls tab. Saving any opening
re-runs the month-end controls automatically.
</p>
</div>
);
}
function Row({ r, draft, onDraft }: {
r: OpeningWorksheetRowT; draft: string; onDraft: (v: string) => void;
}) {
const edited = draft.trim() !== "";
return (
<tr className={edited ? "bg-primary-soft/30" : ""}>
<td className="td font-medium">{r.marketplace}
<span className="text-xs text-subink ml-1.5">{r.currency}</span></td>
<td className="td text-right">
<input
className="input num py-1 w-36 text-right"
value={edited ? draft : ""}
placeholder={acct(r.opening)}
onChange={(e) => onDraft(e.target.value)}
/>
</td>
<td className="td">
<StatusBadge status={r.source === "carried_forward" ? "info"
: r.source === "manual" ? "draft" : "draft"} />
<span className="text-xs text-subink ml-1">
{r.source === "carried_forward" ? "carried forward" : r.source}
</span>
</td>
<td className="td text-right num">{acct(r.net_revenue)}</td>
<td className="td text-right num">{acct(-r.payouts_received)}</td>
<td className="td text-right num font-medium">{acct(r.roll_forward_closing)}</td>
<td className="td text-right num font-medium">
{r.settlement_closing != null ? acct(r.settlement_closing) : "—"}
</td>
<td className={`td text-right num ${r.reconciled ? "text-ok" : "text-warn font-medium"}`}>
{r.variance != null ? acct(r.variance) : "—"}
</td>
<td className="td">
{r.reconciled
? <CheckCircle2 size={15} className="text-ok" />
: <span className="text-xs text-subink whitespace-nowrap"
title={`Setting the opening to ${money(r.implied_opening ?? 0, r.currency, 2)} would close the variance exactly — only do that if it matches last month's closing workbook.`}>
implied {r.implied_opening != null ? acct(r.implied_opening) : "—"}
</span>}
</td>
</tr>
);
}

View File

@ -3,12 +3,14 @@ import { ArrowRight } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "../../api/client"; import { api } from "../../api/client";
import { usd, int } from "../../lib/format"; import { usd, int } from "../../lib/format";
import { Kpi, Section, EmptyState, StatusBadge } from "../../components/ui"; import { BlockedNotice, InfoTip, Kpi, Section, EmptyState, StatusBadge, useDefinitions } from "../../components/ui";
import { useClosing } from "../Closing"; import { useClosing } from "../Closing";
export default function Overview() { export default function Overview() {
const { id, processed } = useClosing(); const { id, processed } = useClosing();
const { data: summary } = useQuery({ queryKey: ["summary", id], queryFn: () => api.summary(id), enabled: processed }); const { data: summary } = useQuery({ queryKey: ["summary", id], queryFn: () => api.summary(id), enabled: processed });
const { data: controls } = useQuery({ queryKey: ["controls", id], queryFn: () => api.controls(id), enabled: processed });
const defs = useDefinitions();
if (!processed) if (!processed)
return <EmptyState title="Not processed yet" return <EmptyState title="Not processed yet"
@ -16,14 +18,31 @@ export default function Overview() {
action={<Link to="upload" className="btn-primary">Go to Upload</Link>} />; action={<Link to="upload" className="btn-primary">Go to Upload</Link>} />;
if (!summary) return null; if (!summary) return null;
// A blocked closing returns {available:false, blocked:true} with no figures at all —
// render the reason, never a partially-populated set of KPIs.
if (summary.blocked) return <BlockedNotice reason={summary.blocked_reason} />;
const exc = summary.exceptions_by_severity ?? {}; const exc = summary.exceptions_by_severity ?? {};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Kpi label="Closing Amazon Receivable" tone="primary" value={usd(summary.closing_receivable_usd)} /> <Kpi label={<>Closing Amazon Receivable
<Kpi label="Reconciliation" value={<StatusBadge status={summary.reconciliation_status} />} mono={false} <InfoTip def={defs.closing_receivable_usd} label="Closing Amazon Receivable" /></>}
sub={`reserve ${usd(summary.reserve_total, 2)}`} /> tone="primary" value={usd(summary.closing_receivable_usd)} />
{/* Month-end controls NOT the old "Reconciled" badge, which came from an identity
that re-summed the same buckets it had just added up and so could never fail. */}
<Kpi label="Month-end controls" mono={false}
tone={controls?.blocked ? "bad" : controls && controls.failed > 0 ? "warn" : "ok"}
value={
controls
? <Link to="controls" className="hover:underline">
<StatusBadge status={controls.blocked ? "unreconciled"
: controls.failed > 0 ? "review" : "reconciled"} />
</Link>
: <StatusBadge status={null} />
}
sub={controls ? `${controls.passed}/${controls.total} passed · reserve ${usd(summary.reserve_total, 2)}`
: `reserve ${usd(summary.reserve_total, 2)}`} />
<Kpi label="Unpaid settlements" value={int(summary.num_receivable_settlements)} <Kpi label="Unpaid settlements" value={int(summary.num_receivable_settlements)}
sub={`${int(summary.num_paid_settlements)} paid · ${int(summary.num_settlements)} total`} /> sub={`${int(summary.num_paid_settlements)} paid · ${int(summary.num_settlements)} total`} />
<Kpi label="Exceptions" tone={exc.error ? "bad" : exc.warning ? "warn" : "ok"} <Kpi label="Exceptions" tone={exc.error ? "bad" : exc.warning ? "warn" : "ok"}
@ -32,9 +51,15 @@ export default function Overview() {
</div> </div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Kpi label="Receivable orders" value={usd(summary.receivable_orders, 2)} /> <Kpi label={<>Receivable orders
<Kpi label="Paid orders (settled)" value={usd(summary.paid_orders, 2)} /> <InfoTip def={defs.receivable_orders} label="Receivable orders" /></>}
<Kpi label="Transfers / disbursements" value={usd(summary.transfers_total, 2)} /> value={usd(summary.receivable_orders, 2)} />
<Kpi label={<>Paid orders (settled)
<InfoTip def={defs.paid_orders} label="Paid orders" /></>}
value={usd(summary.paid_orders, 2)} />
<Kpi label={<>Transfers / disbursements
<InfoTip def={defs.transfers_total} label="Transfers / disbursements" /></>}
value={usd(summary.transfers_total, 2)} />
<Kpi label="Receivable transactions" value={int(summary.num_receivable_transactions)} <Kpi label="Receivable transactions" value={int(summary.num_receivable_transactions)}
sub={`${int(summary.num_transactions)} total rows`} /> sub={`${int(summary.num_transactions)} total rows`} />
</div> </div>

View File

@ -2,7 +2,7 @@ import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { CheckCircle2, AlertTriangle, ArrowDown, ArrowUp, Minus } from "lucide-react"; import { CheckCircle2, AlertTriangle, ArrowDown, ArrowUp, Minus } from "lucide-react";
import { api, ReconLineT } from "../../api/client"; import { api, ReconLineT } from "../../api/client";
import { usd, money } from "../../lib/format"; import { usd, money } from "../../lib/format";
import { Section, EmptyState, StatusBadge } from "../../components/ui"; import { InfoTip, Section, EmptyState, StatusBadge, useDefinitions } from "../../components/ui";
import ReconciliationControl from "../../components/ReconciliationControl"; import ReconciliationControl from "../../components/ReconciliationControl";
import { AllMarketsTable, MarketTabs, isAll, useMarket, ALL_MARKETS } from "../../components/market"; import { AllMarketsTable, MarketTabs, isAll, useMarket, ALL_MARKETS } from "../../components/market";
import { useClosing } from "../Closing"; import { useClosing } from "../Closing";
@ -25,6 +25,7 @@ function Effect({ effect }: { effect: ReconLineT["effect"] }) {
export default function Reconciliation() { export default function Reconciliation() {
const { id, processed } = useClosing(); const { id, processed } = useClosing();
const defs = useDefinitions();
const [sel, setSel] = useMarket(); const [sel, setSel] = useMarket();
const showAll = isAll(sel); const showAll = isAll(sel);
const mktParam = showAll ? undefined : sel; const mktParam = showAll ? undefined : sel;
@ -50,13 +51,14 @@ export default function Reconciliation() {
const m = (v: number | null | undefined, dp = 2) => money(v, cur, dp); const m = (v: number | null | undefined, dp = 2) => money(v, cur, dp);
const multi = (detail?.marketplaces?.length ?? 0) > 1; const multi = (detail?.marketplaces?.length ?? 0) > 1;
const rows: [string, number][] = [ // [label, definition key for the (i) button, value]
["Total uploaded transaction value", data.uploaded_total], const rows: [string, string, number][] = [
["Receivable orders (open settlements)", data.receivable_orders], ["Total uploaded transaction value", "uploaded_total", data.uploaded_total],
["Paid orders (settled)", data.paid_orders], ["Receivable orders (open settlements)", "receivable_orders", data.receivable_orders],
["Transfers / disbursements", data.transfers_total], ["Paid orders (settled)", "paid_orders", data.paid_orders],
["Net Closing Balance (reserve)", data.reserve_total], ["Transfers / disbursements", "transfers_total", data.transfers_total],
["Manual adjustments", data.manual_adjustments], ["Net Closing Balance (reserve)", "reserve", data.reserve_total],
["Manual adjustments", "", data.manual_adjustments],
]; ];
return ( return (
@ -168,14 +170,16 @@ export default function Reconciliation() {
subtitle="Whole-closing identity check across every uploaded file."> subtitle="Whole-closing identity check across every uploaded file.">
<table className="w-full"> <table className="w-full">
<tbody> <tbody>
{rows.map(([label, val]) => ( {rows.map(([label, defKey, val]) => (
<tr key={label}> <tr key={label}>
<td className="td text-subink">{label}</td> <td className="td text-subink">{label}
{defKey && <InfoTip def={defs[defKey]} label={label} />}</td>
<td className="td text-right num">{usd(val, 2)}</td> <td className="td text-right num">{usd(val, 2)}</td>
</tr> </tr>
))} ))}
<tr className="bg-canvas/60"> <tr className="bg-canvas/60">
<td className="td font-semibold">Final closing receivable (USD)</td> <td className="td font-semibold">Final closing receivable (USD)
<InfoTip def={defs.closing_receivable_usd} label="Final closing receivable" /></td>
<td className="td text-right num font-semibold">{usd(data.final_receivable_usd, 2)}</td> <td className="td text-right num font-semibold">{usd(data.final_receivable_usd, 2)}</td>
</tr> </tr>
</tbody> </tbody>

254
start.command Executable file
View File

@ -0,0 +1,254 @@
#!/bin/bash
#
# Amazon A/R Aging Dashboard — double-click launcher (macOS)
#
# Starts the FastAPI backend and the Vite frontend, waits until both are actually
# answering, then opens the dashboard in your browser. Leave this Terminal window open
# while you work; press Ctrl-C (or just close the window) to stop both servers.
#
# Everything runs on this machine. No transaction data leaves your computer.
set -u -o pipefail
# --- resolve our own directory ------------------------------------------------------
# The project path contains spaces and an "&", so every path stays quoted throughout.
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
cd -- "$HERE" || exit 1
APP_DIR="$HERE/ar-aging-app"
BACKEND_DIR="$APP_DIR/backend"
FRONTEND_DIR="$APP_DIR/frontend"
LOG_DIR="$APP_DIR/backend/data/logs"
BACKEND_LOG="$LOG_DIR/backend.log"
FRONTEND_LOG="$LOG_DIR/frontend.log"
BACKEND_PORT=8000 # the Vite proxy hard-codes localhost:8000 — don't change one without the other
FRONTEND_PORT=5173
DASHBOARD_URL="http://localhost:$FRONTEND_PORT"
# --- Finder gives a minimal PATH; put the usual tool locations back ------------------
# Double-clicking does not run your shell profile, so node/npm installed under
# ~/.local/bin or Homebrew would otherwise be "command not found".
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/Library/Frameworks/Python.framework/Versions/3.11/bin:$PATH"
# --- pretty output ------------------------------------------------------------------
if [ -t 1 ]; then
B=$'\033[1m'; DIM=$'\033[2m'; R=$'\033[0m'
OK=$'\033[32m'; WARN=$'\033[33m'; ERR=$'\033[31m'; ACCENT=$'\033[35m'
else
B=""; DIM=""; R=""; OK=""; WARN=""; ERR=""; ACCENT=""
fi
say() { printf '%s\n' "$*"; }
step() { printf ' %s→%s %s\n' "$DIM" "$R" "$*"; }
good() { printf ' %s✓%s %s\n' "$OK" "$R" "$*"; }
warn() { printf ' %s!%s %s\n' "$WARN" "$R" "$*"; }
fail() { printf ' %s✗%s %s\n' "$ERR" "$R" "$*"; }
die() {
printf '\n%s%sCould not start the dashboard.%s\n\n' "$ERR" "$B" "$R"
printf ' %s\n\n' "$1"
[ $# -gt 1 ] && printf ' Try: %s%s%s\n\n' "$B" "$2" "$R"
printf '%sPress Return to close this window.%s\n' "$DIM" "$R"
read -r _
exit 1
}
say ""
say "${ACCENT}${B} Amazon A/R Aging — Month-End Closing${R}"
say "${DIM} $HERE${R}"
say ""
# --- 1. prerequisites ---------------------------------------------------------------
say "${B}1. Checking prerequisites${R}"
[ -d "$BACKEND_DIR" ] || die "Backend folder not found at: $BACKEND_DIR" \
"keep start.command in the same folder as ar-aging-app/"
command -v python3 >/dev/null 2>&1 || die "python3 was not found." \
"install Python 3.11+ from python.org"
command -v npm >/dev/null 2>&1 || die "npm was not found." \
"install Node.js 20+ from nodejs.org"
good "python $(python3 -V 2>&1 | awk '{print $2}') · node $(node -v 2>/dev/null) · npm $(npm -v 2>/dev/null)"
# Python packages — actually IMPORT the app rather than checking a few package names.
# "Installed" is not the same as "compatible": an unpinned starlette upgrade once satisfied
# every import check while breaking the app at load time. Importing proves it will boot.
import_error=""
if ! import_error="$(cd -- "$BACKEND_DIR" && python3 -c "from app.api.main import app" 2>&1)"; then
warn "the backend could not be loaded:"
printf '%s\n' "$import_error" | tail -n 3 | sed 's/^/ /'
printf ' Install/repair Python packages now? [Y/n] '
read -r reply
case "${reply:-Y}" in
[Nn]*) die "The backend cannot start with the current Python packages." \
"python3 -m pip install -r '$BACKEND_DIR/requirements.txt'" ;;
esac
step "installing Python packages (this can take a minute)…"
python3 -m pip install -q -r "$BACKEND_DIR/requirements.txt" \
|| die "pip install failed — see the messages above." \
"python3 -m pip install -r '$BACKEND_DIR/requirements.txt'"
if ! import_error="$(cd -- "$BACKEND_DIR" && python3 -c "from app.api.main import app" 2>&1)"; then
printf '%s\n' "$import_error" | tail -n 5 | sed 's/^/ /'
die "The backend still cannot be loaded after installing packages." \
"python3 -m pip check"
fi
good "Python packages repaired"
else
good "Python packages present and compatible"
fi
# Frontend packages — safe to install unattended, they're local to the project.
if [ ! -d "$FRONTEND_DIR/node_modules" ]; then
step "installing frontend packages (first run only, ~1 minute)…"
( cd -- "$FRONTEND_DIR" && npm install --silent ) \
|| die "npm install failed — see the messages above." \
"cd '$FRONTEND_DIR' && npm install"
good "frontend packages installed"
else
good "frontend packages present"
fi
# --- 2. ports -----------------------------------------------------------------------
# A leftover server from a previous run holds the port and the launch silently fails.
# The Vite proxy points at a fixed localhost:8000, so we can't just pick another port.
free_port() {
local port="$1" label="$2" pids
pids="$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null)"
[ -z "$pids" ] && { good "port $port free ($label)"; return 0; }
warn "port $port is already in use ($label):"
local pid
for pid in $pids; do
printf ' pid %-7s %s\n' "$pid" "$(ps -p "$pid" -o command= 2>/dev/null | cut -c1-88)"
done
printf ' Stop it and continue? [Y/n] '
read -r reply
case "${reply:-Y}" in
[Nn]*) die "Port $port is in use, so the dashboard cannot start." \
"quit the other program, or close the old dashboard window" ;;
esac
for pid in $pids; do kill "$pid" 2>/dev/null; done
for _ in 1 2 3 4 5 6 7 8 9 10; do
sleep 0.3
[ -z "$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null)" ] && break
done
# Still holding on? Escalate once.
pids="$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null)"
if [ -n "$pids" ]; then
for pid in $pids; do kill -9 "$pid" 2>/dev/null; done
sleep 1
fi
[ -n "$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null)" ] \
&& die "Port $port is still in use after trying to stop it." \
"restart your Mac, or find the process with: lsof -i :$port"
good "port $port freed"
}
say ""
say "${B}2. Checking ports${R}"
free_port "$BACKEND_PORT" "backend"
free_port "$FRONTEND_PORT" "frontend"
# --- 3. start the servers -----------------------------------------------------------
mkdir -p "$LOG_DIR"
BACKEND_PID=""
FRONTEND_PID=""
shutdown() {
printf '\n%sStopping…%s\n' "$DIM" "$R"
# Kill the whole process group of each server: uvicorn --reload and vite both fork.
[ -n "$FRONTEND_PID" ] && kill -- "-$FRONTEND_PID" 2>/dev/null
[ -n "$BACKEND_PID" ] && kill -- "-$BACKEND_PID" 2>/dev/null
sleep 0.5
[ -n "$FRONTEND_PID" ] && kill -9 -- "-$FRONTEND_PID" 2>/dev/null
[ -n "$BACKEND_PID" ] && kill -9 -- "-$BACKEND_PID" 2>/dev/null
printf '%sBoth servers stopped.%s\n\n' "$OK" "$R"
exit 0
}
trap shutdown INT TERM
say ""
say "${B}3. Starting servers${R}"
step "backend (FastAPI on :$BACKEND_PORT)"
# setsid-style: run in its own process group so shutdown() can take down the reloader too.
set -m
python3 -m uvicorn app.api.main:app \
--app-dir "$BACKEND_DIR" \
--host 127.0.0.1 --port "$BACKEND_PORT" \
>"$BACKEND_LOG" 2>&1 &
BACKEND_PID=$!
set +m
# Wait for it to actually answer — "process started" is not the same as "server ready".
backend_ready=""
for _ in $(seq 1 60); do
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
say ""
tail -n 25 "$BACKEND_LOG"
die "The backend exited during startup (log above)." "full log: $BACKEND_LOG"
fi
if curl -fsS "http://127.0.0.1:$BACKEND_PORT/api/health" >/dev/null 2>&1; then
backend_ready=1; break
fi
sleep 0.5
done
[ -n "$backend_ready" ] || { tail -n 25 "$BACKEND_LOG"; \
die "The backend did not respond within 30 seconds." "full log: $BACKEND_LOG"; }
good "backend ready"
step "frontend (Vite on :$FRONTEND_PORT)"
set -m
npm --prefix "$FRONTEND_DIR" run dev >"$FRONTEND_LOG" 2>&1 &
FRONTEND_PID=$!
set +m
frontend_ready=""
for _ in $(seq 1 60); do
if ! kill -0 "$FRONTEND_PID" 2>/dev/null; then
say ""
tail -n 25 "$FRONTEND_LOG"
die "The frontend exited during startup (log above)." "full log: $FRONTEND_LOG"
fi
if curl -fsS -o /dev/null "$DASHBOARD_URL" 2>/dev/null; then
frontend_ready=1; break
fi
sleep 0.5
done
[ -n "$frontend_ready" ] || { tail -n 25 "$FRONTEND_LOG"; \
die "The frontend did not respond within 30 seconds." "full log: $FRONTEND_LOG"; }
good "frontend ready"
# End-to-end check: the browser reaches the API *through* the Vite proxy, not directly.
if curl -fsS -o /dev/null "$DASHBOARD_URL/api/sessions" 2>/dev/null; then
good "dashboard is talking to the API"
else
warn "the API proxy did not answer — the dashboard may show loading errors"
fi
# --- 4. open it ---------------------------------------------------------------------
say ""
say "${B}4. Opening the dashboard${R}"
open "$DASHBOARD_URL" 2>/dev/null && good "$DASHBOARD_URL" \
|| warn "could not open a browser — go to $DASHBOARD_URL yourself"
say ""
say "${OK}${B} Dashboard is running.${R}"
say ""
say " Dashboard ${B}$DASHBOARD_URL${R}"
say " API docs ${DIM}http://localhost:$BACKEND_PORT/docs${R}"
say " Logs ${DIM}$LOG_DIR${R}"
say ""
say "${DIM} Keep this window open while you work.${R}"
say "${DIM} Press Ctrl-C to stop both servers.${R}"
say ""
# Stay alive until a server dies or the user interrupts.
while kill -0 "$BACKEND_PID" 2>/dev/null && kill -0 "$FRONTEND_PID" 2>/dev/null; do
sleep 1
done
say ""
fail "A server stopped unexpectedly. Last lines of each log:"
say ""
say "${DIM}--- backend ---${R}"; tail -n 12 "$BACKEND_LOG" 2>/dev/null
say "${DIM}--- frontend ---${R}"; tail -n 12 "$FRONTEND_LOG" 2>/dev/null
shutdown