Enhance API functionality and session management #1
|
|
@ -23,6 +23,32 @@ def get_session_or_404(session_id: int, db: OrmSession) -> models.Session:
|
|||
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 ._,()\-]+")
|
||||
|
||||
|
||||
|
|
@ -51,6 +77,10 @@ def session_dict(s: models.Session) -> dict:
|
|||
"opening_mode", "opening_source_session_id",
|
||||
"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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from ..config import CORS_ORIGINS
|
|||
from ..db.database import init_db
|
||||
from .routes import (
|
||||
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(settings_routes.router)
|
||||
app.include_router(settings_routes.rules_router)
|
||||
app.include_router(settings_routes.meta_router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(ar.router)
|
||||
app.include_router(control.router)
|
||||
app.include_router(analytics.router)
|
||||
app.include_router(controls.router)
|
||||
app.include_router(payouts.router)
|
||||
app.include_router(accounts_summary.router)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -18,12 +18,13 @@ from collections import defaultdict
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session as OrmSession
|
||||
|
||||
from ...core.i18n import currency_for_region, default_fx_for_region
|
||||
from ...db import models
|
||||
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"])
|
||||
|
||||
|
|
@ -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,
|
||||
frm: dt.date | None, to: dt.date | None) -> list[tuple[dt.date, float, float, int]]:
|
||||
"""Per-day (date, revenue_total, payout_total, row_count) for one marketplace."""
|
||||
frm: dt.date | None, to: dt.date | None) -> list[tuple[dt.date, float, int]]:
|
||||
"""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(
|
||||
models.Transaction.posted_date,
|
||||
models.Transaction.txn_type_en,
|
||||
models.Transaction.total,
|
||||
).filter(
|
||||
models.Transaction.session_id == session_id,
|
||||
models.Transaction.marketplace == marketplace,
|
||||
models.Transaction.txn_type_en != TRANSFER,
|
||||
)
|
||||
if 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
|
||||
q = q.filter(models.Transaction.posted_date.isnot(None))
|
||||
|
||||
per: dict[dt.date | None, list[float]] = defaultdict(lambda: [0.0, 0.0, 0])
|
||||
for posted, type_en, total in q:
|
||||
per: dict[dt.date | None, list[float]] = defaultdict(lambda: [0.0, 0])
|
||||
for posted, total in q:
|
||||
if posted is None:
|
||||
d = None
|
||||
else:
|
||||
d = posted if isinstance(posted, dt.date) else dt.date.fromisoformat(str(posted))
|
||||
slot = per[d]
|
||||
if type_en == TRANSFER:
|
||||
slot[1] += total or 0.0
|
||||
else:
|
||||
slot[0] += total or 0.0
|
||||
slot[2] += 1
|
||||
return [(d, v[0], v[1], int(v[2]))
|
||||
slot[1] += 1
|
||||
return [(d, v[0], int(v[1]))
|
||||
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
|
||||
@router.get("/{session_id}/ledger-detail")
|
||||
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}
|
||||
mv.pop("journal", None)
|
||||
mkt = mv["marketplace"]
|
||||
cutoff = _session_cutoff(s)
|
||||
|
||||
frm, to = _parse_date(date_from, "date_from"), _parse_date(date_to, "date_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] = {}
|
||||
for d, revenue, payout, n in rows:
|
||||
for d, revenue, n in rows:
|
||||
key, label = _bucket(d, granularity)
|
||||
b = buckets.setdefault(key, {
|
||||
"key": key, "label": label, "revenue": 0.0,
|
||||
"payouts_received": 0.0, "payouts_in_transit": 0.0, "rows": 0,
|
||||
})
|
||||
b = buckets.setdefault(key, new_bucket(key, label))
|
||||
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
|
||||
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"]
|
||||
running = opening
|
||||
|
|
@ -157,6 +214,7 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity:
|
|||
"revenue": round(b["revenue"], 2),
|
||||
"payouts_received": round(b["payouts_received"], 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"],
|
||||
"balance": round(running, 2),
|
||||
})
|
||||
|
|
@ -367,8 +425,6 @@ def all_markets(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
|
|||
return {"available": False}
|
||||
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(
|
||||
models.ReceivableResultRow.session_id == session_id,
|
||||
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"):
|
||||
continue
|
||||
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)
|
||||
closing_local = mv["closing"]
|
||||
row = {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from pydantic import BaseModel
|
||||
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 ...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"])
|
||||
|
||||
|
|
@ -47,7 +48,8 @@ def get_openings(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict
|
|||
@router.put("/{session_id}/opening-balances")
|
||||
def put_openings(session_id: int, items: list[OpeningIn],
|
||||
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(
|
||||
models.OpeningBalance.session_id == session_id)}
|
||||
for it in items:
|
||||
|
|
@ -58,10 +60,20 @@ def put_openings(session_id: int, items: list[OpeningIn],
|
|||
o.amount = it.amount
|
||||
o.reason = it.reason
|
||||
o.source = it.source or "manual"
|
||||
if items:
|
||||
s.opening_mode = "manual"
|
||||
db.commit()
|
||||
_revalidate(db, session_id, s)
|
||||
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]:
|
||||
"""Marketplaces available in a stored journal payload (primary first, then alphabetical)."""
|
||||
primary = journal.get("marketplace")
|
||||
|
|
@ -84,6 +96,21 @@ def _journal_for(journal: dict, marketplace: str | None) -> tuple[dict, str]:
|
|||
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,
|
||||
markets: list[str]) -> tuple[float, float]:
|
||||
"""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()
|
||||
|
||||
received, all_p = _payouts_for(db, session_id, mkt, markets)
|
||||
rate, currency = fx_for(db, session_id, mkt)
|
||||
mv = compute_movement(
|
||||
journal,
|
||||
received_payouts=received,
|
||||
all_payouts=all_p,
|
||||
opening=opening_row.amount if opening_row else 0.0,
|
||||
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["marketplace"] = mkt
|
||||
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["opening_source"] = opening_row.source if opening_row else "manual"
|
||||
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")
|
||||
def finance_summary(session_id: int, marketplace: str | None = None,
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -250,6 +283,79 @@ def opening_candidates(session_id: int, db: OrmSession = Depends(db_dep)) -> dic
|
|||
"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):
|
||||
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_source_session_id = src_id
|
||||
db.commit()
|
||||
_revalidate(db, session_id, s)
|
||||
return {"applied": len(closings), "from_session_id": src_id, "from": prior.name,
|
||||
"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_source_session_id = None
|
||||
db.commit()
|
||||
_revalidate(db, session_id, s)
|
||||
return {"balances": get_openings(session_id, db)}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from pydantic import BaseModel
|
||||
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 ..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"])
|
||||
|
||||
|
|
@ -24,29 +24,44 @@ METRICS = [
|
|||
]
|
||||
|
||||
|
||||
def _dashboard_metrics(db: OrmSession, session_id: int) -> dict[str, float] | None:
|
||||
"""Whole-close figures: summed across every marketplace in the session.
|
||||
METRIC_KEYS = ("gross_sales", "refunds", "net_revenue", "disbursements", "closing_receivable")
|
||||
|
||||
(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()
|
||||
if not j or not j.data:
|
||||
return None
|
||||
payload = json.loads(j.data)
|
||||
totals = {k: 0.0 for k in ("gross_sales", "refunds", "net_revenue",
|
||||
"disbursements", "closing_receivable")}
|
||||
totals = {k: Total(USD) for k in METRIC_KEYS}
|
||||
for mkt in _market_list(payload):
|
||||
mv = _movement_for(db, session_id, mkt)
|
||||
if not mv.get("available"):
|
||||
continue
|
||||
rate, _currency = fx_for(db, session_id, mkt)
|
||||
lines = mv.pop("journal").get("lines", [])
|
||||
totals["gross_sales"] += next((l["total"] for l in lines if l["key"] == "Sales"), 0.0)
|
||||
totals["refunds"] += next((l["total"] for l in lines if l["key"] == "Refunds"), 0.0)
|
||||
totals["net_revenue"] += mv["net_revenue"]
|
||||
totals["disbursements"] += mv["received_payouts"]
|
||||
totals["closing_receivable"] += mv["closing"]
|
||||
return {k: round(v, 2) for k, v in totals.items()}
|
||||
local = {
|
||||
"gross_sales": next((l["total"] for l in lines if l["key"] == "Sales"), 0.0),
|
||||
"refunds": next((l["total"] for l in lines if l["key"] == "Refunds"), 0.0),
|
||||
"net_revenue": mv["net_revenue"],
|
||||
"disbursements": mv["received_payouts"],
|
||||
"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):
|
||||
|
|
@ -87,6 +102,7 @@ def get_control(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
|
|||
return {
|
||||
"available": True,
|
||||
"tolerance": tol,
|
||||
"currency": USD, # every figure here is USD-converted, never a mix of locals
|
||||
"rows": rows,
|
||||
"verified_by": fc.verified_by if fc else "",
|
||||
"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)
|
||||
fc = _get_or_create(db, session_id)
|
||||
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():
|
||||
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()
|
||||
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")
|
||||
def complete_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
|
||||
s = get_session_or_404(session_id, db)
|
||||
ensure_not_blocked(s)
|
||||
ctrl = get_control(session_id, db)
|
||||
if not ctrl.get("available"):
|
||||
raise HTTPException(400, "Process the closing before completing it.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -9,7 +9,7 @@ from sqlalchemy.orm import Session as OrmSession
|
|||
|
||||
from ...db import models
|
||||
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"])
|
||||
|
||||
|
|
@ -21,6 +21,8 @@ def start_export(session_id: int, background: BackgroundTasks, kind: str = "full
|
|||
if kind not in ("full", "summary"):
|
||||
raise HTTPException(400, "kind must be 'full' or 'summary'.")
|
||||
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"):
|
||||
raise HTTPException(400, "Process the session before 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")
|
||||
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)
|
||||
if kind in ("full", "summary"):
|
||||
q = q.filter(models.ExportRecord.kind == kind)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
@ -1,22 +1,31 @@
|
|||
"""Read endpoints for the dashboard: summary, settlements, transactions, exceptions, etc."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from sqlalchemy import func
|
||||
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 ..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"])
|
||||
|
||||
# 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")
|
||||
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()
|
||||
recon = db.query(models.ReconciliationRow).filter(
|
||||
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"]
|
||||
return {
|
||||
"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,
|
||||
"transfers_total": recon.transfers_total 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")
|
||||
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)
|
||||
j = db.query(models.JournalEntry).filter(
|
||||
models.JournalEntry.session_id == session_id).first()
|
||||
if not j or not j.data:
|
||||
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["marketplace"] = mkt
|
||||
data["marketplaces"] = markets
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -158,16 +185,119 @@ def set_journal_entry_no(session_id: int, entry_no: str = Body(..., embed=True),
|
|||
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")
|
||||
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(
|
||||
models.ReceivableResultRow.session_id == session_id,
|
||||
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 = []
|
||||
for r in rows:
|
||||
band = {b: 0.0 for b in AGING_BANDS}
|
||||
band["Current"] = r.receivable_usd # Amazon receivable is all Current
|
||||
matrix.append({"marketplace": r.marketplace, **band,
|
||||
"Total": r.receivable_usd})
|
||||
return {"bands": list(AGING_BANDS), "rows": matrix}
|
||||
local_bands = by_mkt.get(r.marketplace) or {b: 0.0 for b in AGING_BANDS}
|
||||
composed = sum(local_bands.values())
|
||||
# The receivable is ROUND(reserve + additional sales); the reserve and that rounding
|
||||
# belong to the current period, so the residual lands in Current and the row still
|
||||
# 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")}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,17 @@ from ..deps import db_dep, get_session_or_404, to_dict
|
|||
|
||||
router = APIRouter(prefix="/api/sessions", tags=["settings"])
|
||||
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):
|
||||
|
|
@ -98,11 +109,26 @@ def get_fx(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]:
|
|||
|
||||
@router.put("/{session_id}/fx")
|
||||
def put_fx(session_id: int, items: list[FxIn], db: OrmSession = Depends(db_dep)) -> list[dict]:
|
||||
get_session_or_404(session_id, db)
|
||||
db.query(models.FxRate).filter(models.FxRate.session_id == session_id).delete()
|
||||
s = get_session_or_404(session_id, db)
|
||||
# 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:
|
||||
db.add(models.FxRate(session_id=session_id, marketplace=it.marketplace,
|
||||
currency=it.currency, rate=it.rate, source=it.source,
|
||||
rate_date=it.rate_date))
|
||||
row = existing.get(it.marketplace)
|
||||
if row is None:
|
||||
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()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class CalamineReader:
|
|||
# top-down row scan means the real localized header (row 8) beats any translation
|
||||
# helper row below it.
|
||||
best = None
|
||||
best_key = (-1, -1)
|
||||
best_score = -1
|
||||
for name in self._wb.sheet_names:
|
||||
sheet = self._wb.get_sheet_by_name(name)
|
||||
head = sheet.to_python(nrows=15)
|
||||
|
|
@ -77,9 +77,12 @@ class CalamineReader:
|
|||
continue
|
||||
mapping = build_mapping(cells, r_idx + 1, self.saved_overrides)
|
||||
if _DETECT_REQUIRED.issubset(set(mapping.field_to_col)):
|
||||
key = (len(mapping.field_to_col), self._safe_height(name))
|
||||
if key > best_key:
|
||||
best, best_key = (name, sheet, r_idx, mapping), key
|
||||
# Tie-break MUST match TransactionReader exactly (score only, first wins),
|
||||
# or the two readers can select different worksheets from the same workbook
|
||||
# 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)
|
||||
if not best:
|
||||
raise ParseError(
|
||||
|
|
@ -99,13 +102,23 @@ class CalamineReader:
|
|||
self.file_meta.header_row = self.header_row
|
||||
self.file_meta.unmapped_headers = mapping.unmapped
|
||||
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
|
||||
|
||||
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:
|
||||
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:
|
||||
return 1 << 30
|
||||
return 0
|
||||
|
||||
# -- records --
|
||||
def iter_records(self, only_fields: set[str] | None = None) -> Iterator[dict]:
|
||||
|
|
@ -133,13 +146,15 @@ class CalamineReader:
|
|||
has_value = False
|
||||
for fld, i in items:
|
||||
v = row[i] if i < len(row) else None
|
||||
cv = _conv_cal(fld, v)
|
||||
rec[fld] = cv
|
||||
if cv not in (None, "", 0.0):
|
||||
rec[fld] = _conv_cal(fld, v)
|
||||
# Shared row-emptiness rule (must match TransactionReader exactly): a row counts
|
||||
# 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
|
||||
elif cv == 0.0:
|
||||
has_value = True # a zero amount is still a real cell
|
||||
if not has_value:
|
||||
self.file_meta.blank_rows_skipped += 1
|
||||
continue
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -332,6 +332,10 @@ class ColumnMapping:
|
|||
unmapped: dict[str, str] = field(default_factory=dict)
|
||||
# required fields absent from the header row
|
||||
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
|
||||
|
||||
@property
|
||||
|
|
@ -364,6 +368,12 @@ def build_mapping(
|
|||
if fld and fld not in m.field_to_col:
|
||||
m.col_to_field[col] = fld
|
||||
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():
|
||||
m.unmapped[col] = str(text)
|
||||
m.missing_required = [f for f in REQUIRED_FIELDS if f not in m.field_to_col]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)",
|
||||
},
|
||||
}
|
||||
|
|
@ -163,3 +163,33 @@ def is_storage_like(type_en: str | None, description: str | None) -> bool:
|
|||
return True
|
||||
hay = fold(f"{type_en or ''} {description or ''}")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -18,15 +18,17 @@ from typing import Iterable
|
|||
|
||||
from .readers import make_reader
|
||||
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_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 +
|
||||
# other transaction fees + all non-storage/-shipping "other" transactions), which is how the
|
||||
# Finance sheet books it. Receivable is derived, not accumulated.
|
||||
# other transaction fees + non-storage/-shipping/-advertising "other"); advertising is broken
|
||||
# out to its own line since Amazon only marks it in the description. Receivable is derived,
|
||||
# not accumulated.
|
||||
LINE_ACCOUNTS: list[tuple[str, str]] = [
|
||||
("Sales", "Sales:All Platforms Sales: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 Storage", "FBA Fees:Amazon USA Storage Fees"),
|
||||
("FBA Fee", "FBA Fees:Amazon USA FBA Fees"),
|
||||
("Advertising Cost", "Advertising Expense:Amazon USA Ads"),
|
||||
("Inventory Adjustment", "Sales:Inventory Adjustments:Amazon USA"),
|
||||
("Outward Freight / Shipping", "Outward Freight/ Shipping Expense"),
|
||||
("Transfer", "Amazon USA (bank clearing)"),
|
||||
]
|
||||
LINE_KEYS = [k for k, _ in LINE_ACCOUNTS]
|
||||
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
|
||||
# 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["selling_fees"] += _amt(rec, "selling_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")
|
||||
if o:
|
||||
if ttype == "FBA Inventory Fee":
|
||||
comp["storage_fees"] += o
|
||||
elif ttype == "Shipping Services":
|
||||
comp["freight"] += o
|
||||
elif advertising:
|
||||
comp["advertising"] += o
|
||||
elif ttype == "Adjustment":
|
||||
comp["adjustments"] += o
|
||||
elif "advertis" in tl:
|
||||
comp["advertising"] += o
|
||||
else:
|
||||
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["Tax"] += _tax_sum(rec)
|
||||
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")
|
||||
+ _amt(rec, "other_transaction_fees") + _amt(rec, "regulatory_fee"))
|
||||
+ otf + _amt(rec, "regulatory_fee"))
|
||||
o = _amt(rec, "other")
|
||||
if o:
|
||||
if ttype == "FBA Inventory Fee":
|
||||
acc["FBA Storage"] += o
|
||||
elif ttype == "Shipping Services":
|
||||
acc["Outward Freight / Shipping"] += o
|
||||
elif advertising:
|
||||
acc["Advertising Cost"] += o
|
||||
else:
|
||||
acc["FBA Fee"] += o
|
||||
|
||||
|
|
@ -173,6 +202,16 @@ class JournalResult:
|
|||
return sum(p.receivable for p in self.periods)
|
||||
|
||||
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 {
|
||||
"marketplace": self.marketplace,
|
||||
"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}
|
||||
for p in self.periods],
|
||||
"lines": [
|
||||
{"key": k, "gl_account": acc,
|
||||
{"key": k, "gl_account": gl(acc),
|
||||
"values": [round(p.lines[k], 2) for p in self.periods],
|
||||
"total": round(self.line_total(k), 2)}
|
||||
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],
|
||||
"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": [
|
||||
{"key": k, "label": label, "group": group,
|
||||
"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
|
||||
|
||||
|
||||
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]:
|
||||
"""
|
||||
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
|
||||
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
|
||||
import os
|
||||
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)
|
||||
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"]
|
||||
p = periods.get(region)
|
||||
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")))
|
||||
|
||||
|
||||
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),
|
||||
plus every marketplace under `per_marketplace`."""
|
||||
multi = compute_journals(files, saved_overrides)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -109,6 +109,7 @@ def process(
|
|||
fx_rates: dict[str, float] | None = None,
|
||||
currencies: dict[str, str] | None = None,
|
||||
received_overrides: dict[tuple[str, str, str], bool] | None = None,
|
||||
manual_payouts: bool = False,
|
||||
manual_adjustments: float = 0.0,
|
||||
expected_receivable: float | None = None,
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
|
|
@ -166,7 +167,8 @@ def process(
|
|||
result.file_metas = metas
|
||||
|
||||
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
|
||||
|
||||
emit("Creating receivable aging", 0.92, seen, total_expected or seen)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
**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:
|
||||
final_receivable_usd = Σ_marketplace ROUND(reserve + additional_sales) x fx
|
||||
+ manual_adjustments
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ from datetime import date, timedelta
|
|||
from typing import Iterable
|
||||
|
||||
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
|
||||
# synthetic single stream for marketplaces whose report has no `account type` column
|
||||
# (every marketplace except USA).
|
||||
|
|
@ -96,6 +101,17 @@ class AggregationResult:
|
|||
undated_samples: list[str] = field(default_factory=list)
|
||||
potential_storage_count: int = 0
|
||||
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:
|
||||
|
|
@ -166,8 +182,27 @@ def aggregate(records: Iterable[dict], default_marketplace: str = "USA",
|
|||
if 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).
|
||||
acct_key = acct_raw or _UNSPEC
|
||||
acct_key = account_bucket(acct_raw)
|
||||
key = (marketplace, acct_key, sid)
|
||||
st = settlements.get(key)
|
||||
if st is None:
|
||||
|
|
@ -194,7 +229,13 @@ def aggregate(records: Iterable[dict], default_marketplace: str = "USA",
|
|||
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:
|
||||
|
|
@ -226,11 +267,21 @@ def classify(
|
|||
month_end: date,
|
||||
clearing_lag_days: int = 2,
|
||||
received_overrides: dict[tuple[str, str, str], bool] | None = None,
|
||||
manual_payouts: bool = False,
|
||||
) -> Classification:
|
||||
"""
|
||||
Mark each transfer received/in-transit (auto clearing-lag, with optional overrides
|
||||
keyed by (marketplace, account_type, settlement_id)), derive the paid boundary, and
|
||||
classify every settlement paid/receivable.
|
||||
Mark each transfer received/in-transit, derive the paid boundary, and 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):
|
||||
* A settlement belongs to the marketplace owning the majority of its non-transfer
|
||||
|
|
@ -269,7 +320,8 @@ def classify(
|
|||
for t in agg.transfers:
|
||||
ok = overrides.get((t.marketplace, t.account_type, t.settlement_id))
|
||||
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)
|
||||
if t.received:
|
||||
owner = cls.settlement_owner.get(t.settlement_id, t.marketplace)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""
|
||||
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.)
|
||||
"""
|
||||
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,
|
||||
journal: dict, meta: dict | None = None) -> str:
|
||||
journal: dict, meta: dict | None = None,
|
||||
controls: dict | None = None) -> str:
|
||||
meta = meta or {}
|
||||
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.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 ----------------
|
||||
wa = wb.create_sheet("Audit Trail")
|
||||
wa.column_dimensions["A"].width = 30
|
||||
|
|
|
|||
|
|
@ -67,12 +67,31 @@ class FileMeta:
|
|||
marketplace: str | None = None
|
||||
unmapped_headers: dict[str, str] = field(default_factory=dict)
|
||||
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.
|
||||
helper_rows_skipped: int = 0
|
||||
# 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.
|
||||
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:
|
||||
# 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.unmapped_headers = mapping.unmapped
|
||||
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
|
||||
|
||||
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 -------------------------------------------------------------
|
||||
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:
|
||||
continue
|
||||
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
|
||||
if not has_value:
|
||||
self.file_meta.blank_rows_skipped += 1
|
||||
continue
|
||||
# track meta cheaply
|
||||
d = None
|
||||
|
|
|
|||
|
|
@ -94,6 +94,25 @@ def _migrate() -> None:
|
|||
("eta_seconds", "INTEGER DEFAULT 0"),
|
||||
("opening_mode", "VARCHAR(255) DEFAULT 'zero'"),
|
||||
("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": [
|
||||
("received_payouts", "FLOAT DEFAULT 0"),
|
||||
|
|
|
|||
|
|
@ -30,7 +30,18 @@ class Session(Base):
|
|||
# How the opening AR balance is established: zero (default) | carry_forward | manual
|
||||
opening_mode = Column(String(32), default="zero")
|
||||
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_pct = Column(Float, default=0.0)
|
||||
progress_rows_done = Column(Integer, default=0)
|
||||
|
|
@ -64,6 +75,10 @@ class SessionFile(Base):
|
|||
marketplace = Column(String(64))
|
||||
status = Column(String(32), default="uploaded") # uploaded|parsed|invalid
|
||||
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")
|
||||
|
||||
|
||||
|
|
@ -147,9 +162,55 @@ class FxRate(Base):
|
|||
rate = Column(Float, default=1.0)
|
||||
source = Column(String(64), default="manual")
|
||||
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")
|
||||
|
||||
|
||||
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):
|
||||
__tablename__ = "reserves"
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
|
@ -261,6 +322,14 @@ class JournalEntry(Base):
|
|||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=True)
|
||||
entry_no = Column(String(128), default="")
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -43,6 +43,20 @@ def run_processing(session_id: int) -> None:
|
|||
currencies = {**CURRENCY_BY_REGION, **{r.marketplace: r.currency for r in fx_rows}}
|
||||
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.error = ""
|
||||
session.progress_stage = "Reading workbook"
|
||||
|
|
@ -69,6 +83,7 @@ def run_processing(session_id: int) -> None:
|
|||
month_end=session.month_end_date,
|
||||
clearing_lag_days=session.clearing_lag_days or 2,
|
||||
reserves=reserves, fx_rates=fx_rates, currencies=currencies,
|
||||
received_overrides=received_overrides, manual_payouts=manual_payouts,
|
||||
manual_adjustments=session.manual_adjustment or 0.0,
|
||||
tolerance=session.rounding_tolerance or 0.01,
|
||||
saved_column_overrides=mapping_rules,
|
||||
|
|
@ -97,17 +112,41 @@ def run_processing(session_id: int) -> None:
|
|||
import json
|
||||
from ..core.journal import journal_payload
|
||||
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(
|
||||
models.JournalEntry.session_id == session_id).delete()
|
||||
db.add(models.JournalEntry(session_id=session_id, data=json.dumps(payload)))
|
||||
models.JournalEntry.session_id == session_id).delete(synchronize_session=False)
|
||||
db.add(models.JournalEntry(session_id=session_id, data=json.dumps(payload),
|
||||
entry_no=entry_no))
|
||||
db.commit()
|
||||
except Exception: # noqa: BLE001 — journal is supplementary; never fail the close over it
|
||||
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.progress_stage = "Done"
|
||||
session.progress_pct = 1.0
|
||||
session.needs_reprocess = False # this run reflects the receipts as of now
|
||||
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
|
||||
db.rollback()
|
||||
session = db.get(models.Session, session_id)
|
||||
|
|
@ -119,6 +158,35 @@ def run_processing(session_id: int) -> None:
|
|||
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:
|
||||
session.status = "error"
|
||||
session.error = message
|
||||
|
|
@ -179,7 +247,9 @@ def run_summary_export(session_id: int) -> None:
|
|||
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
month = session.reporting_month or "output"
|
||||
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")
|
||||
|
||||
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
|
||||
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,
|
||||
clearing_lag_days=session.clearing_lag_days or 2,
|
||||
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,
|
||||
tolerance=session.rounding_tolerance or 0.01,
|
||||
saved_column_overrides=mapping_rules, progress=progress)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from typing import Any
|
|||
from sqlalchemy.orm import Session as OrmSession
|
||||
|
||||
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.database import ENGINE
|
||||
|
||||
|
|
@ -40,7 +40,11 @@ class TransactionSink:
|
|||
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("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),
|
||||
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:
|
||||
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.commit()
|
||||
|
||||
|
|
@ -105,7 +110,7 @@ _CHILD_MODELS = (
|
|||
models.ReconciliationRow, models.MarketPayout, models.Exception_,
|
||||
models.OpeningBalance, models.FxRate, models.FxRateDaily, models.Reserve,
|
||||
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.currency = m.currency
|
||||
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"
|
||||
if m.missing_required:
|
||||
f.message = f"missing required columns: {m.missing_required}"
|
||||
|
||||
# settlements (+ attach boundary transfer info)
|
||||
boundary_tx = {k: t for k, t in (cls.boundary_transfer or {}).items() if t}
|
||||
# Per-payout facts, keyed like the engine classifies: (marketplace, acct bucket, sid).
|
||||
# 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():
|
||||
row = models.Settlement(
|
||||
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,
|
||||
status=st.status,
|
||||
)
|
||||
t = boundary_tx.get((mkt, acct))
|
||||
if t and t.settlement_id == sid:
|
||||
row.transfer_amount = t.amount
|
||||
row.transfer_date = t.txn_date
|
||||
row.transfer_received = t.received
|
||||
info = tx_info.get((mkt, acct, sid))
|
||||
if info is not None:
|
||||
row.transfer_amount = info[0]
|
||||
row.transfer_date = info[1]
|
||||
row.transfer_received = info[2]
|
||||
db.add(row)
|
||||
|
||||
# receivable results (per account + TOTAL)
|
||||
|
|
@ -256,6 +276,13 @@ def _exceptions_from(result: ProcessResult) -> list[dict]:
|
|||
for fld in m.missing_required:
|
||||
out.append({"category": "missing_column", "severity": "error",
|
||||
"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():
|
||||
if abs(s) > 0.005:
|
||||
out.append({"category": "unmapped_amounts", "severity": "error",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ python-calamine>=0.3.0
|
|||
|
||||
# API layer
|
||||
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
|
||||
python-multipart==0.0.20
|
||||
pydantic==2.10.4
|
||||
|
|
|
|||
|
|
@ -2,10 +2,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
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_SAMPLE_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
|
@ -34,3 +51,22 @@ def sample_workbook() -> str:
|
|||
if not os.path.exists(p):
|
||||
pytest.skip(f"Sample workbook not found: {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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -108,6 +108,22 @@ def test_per_market_endpoints():
|
|||
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
|
||||
|
||||
# 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"
|
||||
|
||||
mv = c.get(f"/api/sessions/{sid}/ar-movement").json()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -10,8 +10,9 @@ from __future__ import annotations
|
|||
import os
|
||||
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_")
|
||||
os.environ["AR_DATA_DIR"] = _TMP
|
||||
|
||||
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()
|
||||
assert s["opening_mode"] == "zero"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -64,11 +64,12 @@ JOURNAL = {
|
|||
}
|
||||
|
||||
|
||||
def _build(tmp_path):
|
||||
def _build(tmp_path, controls=None):
|
||||
out = str(tmp_path / "summary.xlsx")
|
||||
export_summary_workbook(out, SUMMARY, CONTROL, JOURNAL,
|
||||
{"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)
|
||||
|
||||
|
||||
|
|
@ -80,7 +81,30 @@ def _cells(ws):
|
|||
def test_sheets_present(tmp_path):
|
||||
wb = _build(tmp_path)
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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).*
|
||||
|
|
@ -125,8 +125,20 @@ flowchart TD
|
|||
style I fill:#FAF0DA,stroke:#B37A1B
|
||||
```
|
||||
|
||||
`clearing_lag` defaults to **2 days** and is adjustable per closing. Every payout is listed on the
|
||||
**Settlement Reconciliation** tab where you can override its received/in-transit status.
|
||||
**Bank receipts override the heuristic.** Amazon's Transfer date is when the payout was
|
||||
*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
|
||||
|
||||
|
|
@ -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 |
|
||||
| **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
|
||||
```
|
||||
|
||||
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
|
||||
flowchart LR
|
||||
OV[Overview] --> UP[Upload & Mapping]
|
||||
OV[Overview] --> CT[Controls]
|
||||
CT --> OP[Opening Balances]
|
||||
OP --> UP[Upload & Mapping]
|
||||
UP --> VE[Validation & Exceptions]
|
||||
VE --> FS[Finance Summary]
|
||||
FS --> AL[AR Ledger]
|
||||
|
|
@ -294,17 +343,20 @@ flowchart LR
|
|||
|
||||
| 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 |
|
||||
| **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 | — |
|
||||
| **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 |
|
||||
| **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 |
|
||||
| **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 |
|
||||
|
||||
### The All Markets sub-tab
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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 AccountsSummary from "./pages/AccountsSummary";
|
||||
import NewClosing from "./pages/NewClosing";
|
||||
import Closing from "./pages/Closing";
|
||||
import Settings from "./pages/Settings";
|
||||
|
|
@ -42,6 +43,7 @@ export default function App() {
|
|||
</div>
|
||||
<nav className="px-3 py-2 space-y-1 flex-1">
|
||||
<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="/settings" icon={SettingsIcon}>Settings</SideLink>
|
||||
</nav>
|
||||
|
|
@ -53,6 +55,7 @@ export default function App() {
|
|||
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/accounts" element={<AccountsSummary />} />
|
||||
<Route path="/new" element={<NewClosing />} />
|
||||
<Route path="/closing/:id/*" element={<Closing />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
|
|
|||
|
|
@ -42,6 +42,59 @@ export interface SessionT {
|
|||
error: string;
|
||||
created_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 {
|
||||
|
|
@ -60,7 +113,18 @@ export interface FileT {
|
|||
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;
|
||||
reconciliation_status: string | null;
|
||||
reserve_total: number;
|
||||
|
|
@ -154,9 +218,31 @@ export interface JournalT {
|
|||
available: boolean;
|
||||
entry_no?: string;
|
||||
marketplace?: string;
|
||||
marketplaces?: string[];
|
||||
periods?: { key: string; label: string; min_date: string | null; max_date: string | null }[];
|
||||
lines?: 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 {
|
||||
|
|
@ -166,7 +252,7 @@ export interface ComponentT {
|
|||
values: number[];
|
||||
total: number;
|
||||
}
|
||||
export interface FinanceSummaryT {
|
||||
export interface FinanceSummaryT extends BlockableT {
|
||||
available: boolean;
|
||||
marketplace?: string;
|
||||
marketplaces?: string[];
|
||||
|
|
@ -292,6 +378,41 @@ export interface OpeningBalanceT {
|
|||
reason: 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 {
|
||||
period: string;
|
||||
description: string;
|
||||
|
|
@ -370,11 +491,22 @@ export const api = {
|
|||
req<MappingRulesT>(`/mapping-rules/${ruleId}`, { method: "DELETE" }),
|
||||
reconciliation: (id: number) => req<ReconT>(`/sessions/${id}/reconciliation`),
|
||||
aging: (id: number) =>
|
||||
req<{ bands: string[]; rows: Record<string, number | string>[] }>(`/sessions/${id}/aging`),
|
||||
journal: (id: number) => req<JournalT>(`/sessions/${id}/journal`),
|
||||
req<BlockableT & { bands: string[]; rows: Record<string, number | string>[] }>(
|
||||
`/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) =>
|
||||
req(`/sessions/${id}/journal/entry-no`, { method: "PUT", body: JSON.stringify({ entry_no }) }),
|
||||
openings: (id: number) => req<OpeningBalanceT[]>(`/sessions/${id}/opening-balances`),
|
||||
openingWorksheet: (id: number) =>
|
||||
req<OpeningWorksheetT>(`/sessions/${id}/opening-balances/worksheet`),
|
||||
putOpenings: (id: number, items: OpeningBalanceT[]) =>
|
||||
req<OpeningBalanceT[]>(`/sessions/${id}/opening-balances`, { method: "PUT", body: JSON.stringify(items) }),
|
||||
openingCandidates: (id: number) =>
|
||||
|
|
@ -405,6 +537,27 @@ export const api = {
|
|||
}),
|
||||
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) =>
|
||||
req<{ marketplace: string; account_type: string; amount: number }[]>(`/sessions/${id}/reserves`),
|
||||
putReserves: (id: number, items: { marketplace: string; account_type: string; amount: number }[]) =>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
@ -1,5 +1,17 @@
|
|||
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 }) {
|
||||
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 }: {
|
||||
label: string; value: ReactNode; sub?: ReactNode;
|
||||
label: ReactNode; value: ReactNode; sub?: ReactNode;
|
||||
tone?: "default" | "primary" | "ok" | "warn" | "bad"; mono?: boolean;
|
||||
}) {
|
||||
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 }) {
|
||||
return (
|
||||
<div className="text-center py-14 px-6">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
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 { StatusBadge, ProgressStages, Spinner } from "../components/ui";
|
||||
import { date } from "../lib/format";
|
||||
import Overview from "./closing/Overview";
|
||||
import Controls from "./closing/Controls";
|
||||
import OpeningBalances from "./closing/OpeningBalances";
|
||||
import Upload from "./closing/Upload";
|
||||
import Exceptions from "./closing/Exceptions";
|
||||
import Settlements from "./closing/Settlements";
|
||||
|
|
@ -19,12 +22,27 @@ export interface ClosingCtx { id: number; session: SessionT; processed: boolean
|
|||
export const useClosing = () => useOutletContext<ClosingCtx>();
|
||||
|
||||
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"],
|
||||
["transactions", "Transaction Details"], ["reconciliation", "Reconciliation"],
|
||||
["journal", "Journal Entry"], ["export", "Excel Export"],
|
||||
] 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() {
|
||||
const { id } = useParams();
|
||||
const sid = Number(id);
|
||||
|
|
@ -40,7 +58,10 @@ export default function Closing() {
|
|||
if (isLoading || !session)
|
||||
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 (
|
||||
<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>
|
||||
)}
|
||||
{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">
|
||||
<Routes>
|
||||
<Route element={<Outlet context={{ id: sid, session, processed } satisfies ClosingCtx} />}>
|
||||
<Route index element={<Overview />} />
|
||||
<Route path="controls" element={<Controls />} />
|
||||
<Route path="opening" element={<OpeningBalances />} />
|
||||
<Route path="upload" element={<Upload />} />
|
||||
<Route path="exceptions" element={<Exceptions />} />
|
||||
<Route path="settlements" element={<Settlements />} />
|
||||
|
|
|
|||
|
|
@ -108,5 +108,8 @@ export default function Dashboard() {
|
|||
|
||||
function LatestReceivable({ id }: { id: number }) {
|
||||
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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,25 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { api } from "../../api/client";
|
||||
import { usd } from "../../lib/format";
|
||||
import { Section, EmptyState } from "../../components/ui";
|
||||
import { BlockedNotice, InfoTip, Section, EmptyState, useDefinitions } from "../../components/ui";
|
||||
import { useClosing } from "../Closing";
|
||||
|
||||
export default function Aging() {
|
||||
const { id, processed } = useClosing();
|
||||
const defs = useDefinitions();
|
||||
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 (!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 }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<table className="w-full">
|
||||
<thead><tr>
|
||||
|
|
|
|||
|
|
@ -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 { ArrowDownRight, ArrowUpRight, Pencil, Save, CalendarRange,
|
||||
RotateCcw, CornerDownRight } from "lucide-react";
|
||||
import { api, OpeningBalanceT } from "../../api/client";
|
||||
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 BankReceipts from "../../components/BankReceipts";
|
||||
import { useClosing } from "../Closing";
|
||||
|
||||
type Gran = "day" | "week" | "month";
|
||||
|
|
@ -13,6 +14,7 @@ type Gran = "day" | "week" | "month";
|
|||
export default function ArLedger() {
|
||||
const { id, processed } = useClosing();
|
||||
const qc = useQueryClient();
|
||||
const defs = useDefinitions();
|
||||
const [sel, setSel] = useMarket();
|
||||
const showAll = isAll(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="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">
|
||||
<Row label="Opening AR balance" v={mv.opening!} cur={cur} />
|
||||
<Row label="+ Net revenue (accrued)" v={mv.net_revenue!} cur={cur} pos />
|
||||
<Row label={<>Opening AR balance<InfoTip def={defs.opening_balance} label="Opening AR balance" /></>}
|
||||
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" />
|
||||
<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="flex items-center justify-between pt-1">
|
||||
<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">
|
||||
In-transit payouts of <span className="num">{m(mv.in_transit_payouts)}</span> remain
|
||||
in receivable (not yet cleared).
|
||||
<InfoTip def={defs.in_transit_payouts} label="In-transit payouts" />
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
|
@ -138,6 +145,10 @@ export default function ArLedger() {
|
|||
</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"}`}>
|
||||
<StatusBadge status={reconciled ? "reconciled" : "review"} />
|
||||
<div className="text-sm">
|
||||
|
|
@ -288,7 +299,7 @@ export default function ArLedger() {
|
|||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
|||
import { Download, FileSpreadsheet } from "lucide-react";
|
||||
import { api, ComponentT } from "../../api/client";
|
||||
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 { useClosing } from "../Closing";
|
||||
|
||||
|
|
@ -13,6 +13,7 @@ function Amt({ v, bold }: { v: number | null | undefined; bold?: boolean }) {
|
|||
|
||||
export default function FinanceSummary() {
|
||||
const { id, processed } = useClosing();
|
||||
const defs = useDefinitions();
|
||||
const [sel, setSel] = useMarket();
|
||||
const showAll = isAll(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 (data?.blocked) return <BlockedNotice reason={data.blocked_reason} />;
|
||||
if (!data?.available) return <EmptyState title="Finance summary not available yet." />;
|
||||
|
||||
const cur = data.currency ?? "USD";
|
||||
|
|
@ -39,14 +41,14 @@ export default function FinanceSummary() {
|
|||
|
||||
const Row = ({ c }: { c: ComponentT }) => (
|
||||
<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>)}
|
||||
<td className="td text-right border-l border-line"><Amt v={c.total} bold /></td>
|
||||
</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">
|
||||
<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" />)}
|
||||
<td className="td text-right border-l border-line num font-semibold text-primary">{m(v, 2)}</td>
|
||||
</tr>
|
||||
|
|
@ -93,14 +95,15 @@ export default function FinanceSummary() {
|
|||
</tr></thead>
|
||||
<tbody>
|
||||
<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" />)}
|
||||
<td className="td text-right border-l border-line num font-semibold">{m(data.opening_balance, 2)}</td>
|
||||
</tr>
|
||||
{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} />)}
|
||||
<SubTotal label="Net revenue" v={data.net_revenue} />
|
||||
<SubTotal label="Net revenue" defKey="net_revenue" v={data.net_revenue} />
|
||||
{memo.length > 0 && (
|
||||
<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>
|
||||
|
|
@ -114,23 +117,27 @@ export default function FinanceSummary() {
|
|||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<Section title="Closing receivable calculation">
|
||||
<div className="p-4 space-y-2 text-sm">
|
||||
{[
|
||||
["Opening AR balance", data.opening_balance],
|
||||
["+ Net revenue", data.net_revenue],
|
||||
["= Total Amazon receivable", (data.opening_balance ?? 0) + (data.net_revenue ?? 0)],
|
||||
["− Amazon payouts received", data.disbursements],
|
||||
].map(([label, v]) => (
|
||||
<div key={label as string} className="flex justify-between">
|
||||
<span className={String(label).startsWith("=") ? "font-semibold" : "text-subink"}>{label}</span>
|
||||
{([
|
||||
["Opening AR balance", "opening_balance", data.opening_balance],
|
||||
["+ Net revenue", "net_revenue", data.net_revenue],
|
||||
["= Total Amazon receivable", "", (data.opening_balance ?? 0) + (data.net_revenue ?? 0)],
|
||||
["− Amazon payouts received", "disbursements", data.disbursements],
|
||||
] as [string, string, number | undefined][]).map(([label, defKey, v]) => (
|
||||
<div key={label} className="flex justify-between">
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-xs text-subink pt-1">
|
||||
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>
|
||||
</div>
|
||||
</Section>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,50 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../../api/client";
|
||||
import { acct } from "../../lib/format";
|
||||
import { Section, EmptyState } from "../../components/ui";
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BadgeCheck, CheckCircle2, FileCheck2, RotateCcw } from "lucide-react";
|
||||
import { api, JournalLineT, JournalT } from "../../api/client";
|
||||
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";
|
||||
|
||||
function Amt({ v, bold }: { v: number; bold?: boolean }) {
|
||||
const neg = v < 0;
|
||||
return (
|
||||
<span className={`num ${bold ? "font-semibold" : ""} ${neg ? "text-bad" : "text-ink"}`}>
|
||||
{acct(v)}
|
||||
</span>
|
||||
);
|
||||
/**
|
||||
* Month-end ACCRUAL journal entry, in proper double-entry form.
|
||||
*
|
||||
* The Transfer (bank clearing) line is deliberately not part of this entry: bank receipts
|
||||
* are posted separately from bank statements. The entry books the month's revenue & fees
|
||||
* 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() {
|
||||
const { id, processed } = useClosing();
|
||||
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("");
|
||||
useEffect(() => { if (data?.entry_no !== undefined) setEntryNo(data.entry_no ?? ""); }, [data?.entry_no]);
|
||||
const saveNo = useMutation({
|
||||
|
|
@ -30,16 +57,17 @@ export default function JournalEntry() {
|
|||
return <EmptyState title="Journal entry not available"
|
||||
hint="It is built during processing — re-run the closing if this persists." />;
|
||||
|
||||
const periods = data.periods ?? [];
|
||||
const lines = data.lines ?? [];
|
||||
const rec = data.receivable!;
|
||||
const markets = data.marketplaces ?? [data.marketplace ?? "USA"];
|
||||
const multi = markets.length > 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card p-4 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<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>
|
||||
<label className="label mb-1">Journal Entry #</label>
|
||||
|
|
@ -48,48 +76,237 @@ export default function JournalEntry() {
|
|||
onBlur={() => entryNo !== (data.entry_no ?? "") && saveNo.mutate(entryNo)} />
|
||||
</div>
|
||||
<p className="text-xs text-subink flex-1 min-w-[220px]">
|
||||
Every Amazon transaction column is booked to a GL account per 10‑day period; the
|
||||
<b> Receivable</b> is the balancing figure (−net of all lines) and equals the amount posted to A/R.
|
||||
The month-end <b>accrual</b> entry: revenue & fees per GL account, balanced by a
|
||||
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>
|
||||
{multi && <MarketTabs markets={markets} value={showAll ? ALL_MARKETS : (data.marketplace ?? "")}
|
||||
onChange={setSel} />}
|
||||
</div>
|
||||
|
||||
<Section title="Amazon USA — Journal Entry decomposition"
|
||||
subtitle="Computed from the raw transaction columns; reconciles to the closing receivable.">
|
||||
{showAll
|
||||
? <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">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="th">{periods[0]?.label?.split(" ").slice(-2).join(" ") ?? "Line"}</th>
|
||||
{periods.map((p) => <th key={p.key} className="th text-right">{p.label}</th>)}
|
||||
<th className="th text-right">Total</th>
|
||||
<thead><tr>
|
||||
<th className="th">Line</th>
|
||||
{showPeriods && 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">Credit</th>
|
||||
<th className="th">GL Account</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{lines.map((ln) => (
|
||||
<tr key={ln.key}>
|
||||
<td className="td font-medium">{ln.key}</td>
|
||||
{ln.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={ln.total} bold /></td>
|
||||
<td className="td text-xs text-subink">{ln.gl_account}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-primary-soft/50">
|
||||
<td className="td font-semibold text-primary">Receivable</td>
|
||||
{rec.values.map((v, i) => <td key={i} className="td text-right"><Amt v={v} bold /></td>)}
|
||||
<td className="td text-right border-l border-line"><Amt v={rec.total} bold /></td>
|
||||
<td className="td text-xs text-subink">{rec.gl_account}</td>
|
||||
{lines.map((ln) => <Row key={ln.key} ln={ln} showPeriods={showPeriods}
|
||||
nPeriods={periods.length} def={defs[ln.key]} />)}
|
||||
{rec && <Row ln={rec} showPeriods={showPeriods} nPeriods={periods.length}
|
||||
def={defs.Receivable} highlight />}
|
||||
<tr className="bg-neutralbg/60 font-semibold">
|
||||
<td className="td">Totals</td>
|
||||
{showPeriods && periods.map((p) => <td key={p.key} className="td" />)}
|
||||
<td className="td text-right num border-l border-line">{acct(totDr)}</td>
|
||||
<td className="td text-right num">{acct(totCr)}</td>
|
||||
<td className="td text-xs text-subink">
|
||||
{Math.abs(totDr - totCr) < 0.02
|
||||
? <span className="inline-flex items-center gap-1 text-ok">
|
||||
<CheckCircle2 size={13} /> balanced</span>
|
||||
: <span className="text-bad num">out of balance {acct(totDr - totCr)}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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 catch‑all Amazon fee/adjustment
|
||||
bucket (fba fees + promotional rebates + other transaction fees + non‑storage/‑shipping adjustments).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ ln, showPeriods, nPeriods, def, highlight }: {
|
||||
ln: JournalLineT; showPeriods: boolean; nPeriods: number;
|
||||
def?: { formula: string; source: string; note?: string }; highlight?: boolean;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,12 +3,14 @@ import { ArrowRight } from "lucide-react";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../../api/client";
|
||||
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";
|
||||
|
||||
export default function Overview() {
|
||||
const { id, processed } = useClosing();
|
||||
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)
|
||||
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>} />;
|
||||
|
||||
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 ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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="Reconciliation" value={<StatusBadge status={summary.reconciliation_status} />} mono={false}
|
||||
sub={`reserve ${usd(summary.reserve_total, 2)}`} />
|
||||
<Kpi label={<>Closing Amazon Receivable
|
||||
<InfoTip def={defs.closing_receivable_usd} label="Closing Amazon Receivable" /></>}
|
||||
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)}
|
||||
sub={`${int(summary.num_paid_settlements)} paid · ${int(summary.num_settlements)} total`} />
|
||||
<Kpi label="Exceptions" tone={exc.error ? "bad" : exc.warning ? "warn" : "ok"}
|
||||
|
|
@ -32,9 +51,15 @@ export default function Overview() {
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Kpi label="Receivable orders" value={usd(summary.receivable_orders, 2)} />
|
||||
<Kpi label="Paid orders (settled)" value={usd(summary.paid_orders, 2)} />
|
||||
<Kpi label="Transfers / disbursements" value={usd(summary.transfers_total, 2)} />
|
||||
<Kpi label={<>Receivable orders
|
||||
<InfoTip def={defs.receivable_orders} label="Receivable orders" /></>}
|
||||
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)}
|
||||
sub={`${int(summary.num_transactions)} total rows`} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
|||
import { CheckCircle2, AlertTriangle, ArrowDown, ArrowUp, Minus } from "lucide-react";
|
||||
import { api, ReconLineT } from "../../api/client";
|
||||
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 { AllMarketsTable, MarketTabs, isAll, useMarket, ALL_MARKETS } from "../../components/market";
|
||||
import { useClosing } from "../Closing";
|
||||
|
|
@ -25,6 +25,7 @@ function Effect({ effect }: { effect: ReconLineT["effect"] }) {
|
|||
|
||||
export default function Reconciliation() {
|
||||
const { id, processed } = useClosing();
|
||||
const defs = useDefinitions();
|
||||
const [sel, setSel] = useMarket();
|
||||
const showAll = isAll(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 multi = (detail?.marketplaces?.length ?? 0) > 1;
|
||||
|
||||
const rows: [string, number][] = [
|
||||
["Total uploaded transaction value", data.uploaded_total],
|
||||
["Receivable orders (open settlements)", data.receivable_orders],
|
||||
["Paid orders (settled)", data.paid_orders],
|
||||
["Transfers / disbursements", data.transfers_total],
|
||||
["Net Closing Balance (reserve)", data.reserve_total],
|
||||
["Manual adjustments", data.manual_adjustments],
|
||||
// [label, definition key for the (i) button, value]
|
||||
const rows: [string, string, number][] = [
|
||||
["Total uploaded transaction value", "uploaded_total", data.uploaded_total],
|
||||
["Receivable orders (open settlements)", "receivable_orders", data.receivable_orders],
|
||||
["Paid orders (settled)", "paid_orders", data.paid_orders],
|
||||
["Transfers / disbursements", "transfers_total", data.transfers_total],
|
||||
["Net Closing Balance (reserve)", "reserve", data.reserve_total],
|
||||
["Manual adjustments", "", data.manual_adjustments],
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -168,14 +170,16 @@ export default function Reconciliation() {
|
|||
subtitle="Whole-closing identity check across every uploaded file.">
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{rows.map(([label, val]) => (
|
||||
{rows.map(([label, defKey, val]) => (
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue