From b795d51aa7a1b9df55fe7b99f48a452983687a3b Mon Sep 17 00:00:00 2001 From: sheheryarsoomro12 Date: Mon, 3 Aug 2026 11:38:48 +0500 Subject: [PATCH] Enhance API functionality and session management --- ar-aging-app/backend/app/api/deps.py | 30 ++ ar-aging-app/backend/app/api/main.py | 6 +- .../app/api/routes/accounts_summary.py | 87 +++++ .../backend/app/api/routes/analytics.py | 107 ++++-- ar-aging-app/backend/app/api/routes/ar.py | 116 ++++++- .../backend/app/api/routes/control.py | 55 ++- .../backend/app/api/routes/controls.py | 95 +++++ ar-aging-app/backend/app/api/routes/export.py | 8 +- .../backend/app/api/routes/payouts.py | 177 ++++++++++ .../backend/app/api/routes/results.py | 156 ++++++++- .../backend/app/api/routes/settings.py | 36 +- .../backend/app/core/calamine_reader.py | 37 +- ar-aging-app/backend/app/core/column_map.py | 10 + ar-aging-app/backend/app/core/controls.py | 276 +++++++++++++++ ar-aging-app/backend/app/core/definitions.py | 270 +++++++++++++++ ar-aging-app/backend/app/core/i18n.py | 30 ++ ar-aging-app/backend/app/core/journal.py | 84 ++++- ar-aging-app/backend/app/core/money.py | 78 +++++ ar-aging-app/backend/app/core/pipeline.py | 4 +- .../backend/app/core/reconciliation.py | 13 +- ar-aging-app/backend/app/core/settlements.py | 64 +++- .../backend/app/core/summary_export.py | 34 +- ar-aging-app/backend/app/core/xlsx_reader.py | 44 ++- ar-aging-app/backend/app/db/database.py | 19 + ar-aging-app/backend/app/db/models.py | 71 +++- .../backend/app/services/controls_run.py | 179 ++++++++++ ar-aging-app/backend/app/services/jobs.py | 88 ++++- ar-aging-app/backend/app/services/store.py | 49 ++- ar-aging-app/backend/requirements.txt | 4 + ar-aging-app/backend/tests/conftest.py | 36 ++ ar-aging-app/backend/tests/test_aging.py | 92 +++++ ar-aging-app/backend/tests/test_controls.py | 247 +++++++++++++ .../backend/tests/test_payout_receipts.py | 162 +++++++++ ar-aging-app/backend/tests/test_per_market.py | 16 + .../backend/tests/test_reader_equivalence.py | 142 ++++++++ .../backend/tests/test_session_lifecycle.py | 105 +++++- .../backend/tests/test_summary_export.py | 30 +- ar-aging-app/docs/AUDIT-REPORT.md | 200 +++++++++++ ar-aging-app/docs/SYSTEM-GUIDE.md | 70 +++- ar-aging-app/frontend/src/App.tsx | 5 +- ar-aging-app/frontend/src/api/client.ts | 161 ++++++++- .../frontend/src/components/BankReceipts.tsx | 165 +++++++++ ar-aging-app/frontend/src/components/ui.tsx | 104 +++++- .../frontend/src/pages/AccountsSummary.tsx | 151 ++++++++ ar-aging-app/frontend/src/pages/Closing.tsx | 55 ++- ar-aging-app/frontend/src/pages/Dashboard.tsx | 3 + .../frontend/src/pages/closing/Aging.tsx | 9 +- .../frontend/src/pages/closing/ArLedger.tsx | 25 +- .../frontend/src/pages/closing/Controls.tsx | 136 ++++++++ .../src/pages/closing/FinanceSummary.tsx | 39 ++- .../src/pages/closing/JournalEntry.tsx | 325 +++++++++++++++--- .../src/pages/closing/OpeningBalances.tsx | 211 ++++++++++++ .../frontend/src/pages/closing/Overview.tsx | 39 ++- .../src/pages/closing/Reconciliation.tsx | 26 +- start.command | 254 ++++++++++++++ 55 files changed, 4797 insertions(+), 238 deletions(-) create mode 100644 ar-aging-app/backend/app/api/routes/accounts_summary.py create mode 100644 ar-aging-app/backend/app/api/routes/controls.py create mode 100644 ar-aging-app/backend/app/api/routes/payouts.py create mode 100644 ar-aging-app/backend/app/core/controls.py create mode 100644 ar-aging-app/backend/app/core/definitions.py create mode 100644 ar-aging-app/backend/app/core/money.py create mode 100644 ar-aging-app/backend/app/services/controls_run.py create mode 100644 ar-aging-app/backend/tests/test_aging.py create mode 100644 ar-aging-app/backend/tests/test_controls.py create mode 100644 ar-aging-app/backend/tests/test_payout_receipts.py create mode 100644 ar-aging-app/backend/tests/test_reader_equivalence.py create mode 100644 ar-aging-app/docs/AUDIT-REPORT.md create mode 100644 ar-aging-app/frontend/src/components/BankReceipts.tsx create mode 100644 ar-aging-app/frontend/src/pages/AccountsSummary.tsx create mode 100644 ar-aging-app/frontend/src/pages/closing/Controls.tsx create mode 100644 ar-aging-app/frontend/src/pages/closing/OpeningBalances.tsx create mode 100755 start.command diff --git a/ar-aging-app/backend/app/api/deps.py b/ar-aging-app/backend/app/api/deps.py index 64aff23..5883748 100644 --- a/ar-aging-app/backend/app/api/deps.py +++ b/ar-aging-app/backend/app/api/deps.py @@ -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 diff --git a/ar-aging-app/backend/app/api/main.py b/ar-aging-app/backend/app/api/main.py index 81b6551..4da5ac7 100644 --- a/ar-aging-app/backend/app/api/main.py +++ b/ar-aging-app/backend/app/api/main.py @@ -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) diff --git a/ar-aging-app/backend/app/api/routes/accounts_summary.py b/ar-aging-app/backend/app/api/routes/accounts_summary.py new file mode 100644 index 0000000..101f6b7 --- /dev/null +++ b/ar-aging-app/backend/app/api/routes/accounts_summary.py @@ -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, + } diff --git a/ar-aging-app/backend/app/api/routes/analytics.py b/ar-aging-app/backend/app/api/routes/analytics.py index dd74205..36524ae 100644 --- a/ar-aging-app/backend/app/api/routes/analytics.py +++ b/ar-aging-app/backend/app/api/routes/analytics.py @@ -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[0] += total or 0.0 + 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 = { diff --git a/ar-aging-app/backend/app/api/routes/ar.py b/ar-aging-app/backend/app/api/routes/ar.py index 2b4ba4f..c2d2d6c 100644 --- a/ar-aging-app/backend/app/api/routes/ar.py +++ b/ar-aging-app/backend/app/api/routes/ar.py @@ -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)} diff --git a/ar-aging-app/backend/app/api/routes/control.py b/ar-aging-app/backend/app/api/routes/control.py index 236bbee..232ae0f 100644 --- a/ar-aging-app/backend/app/api/routes/control.py +++ b/ar-aging-app/backend/app/api/routes/control.py @@ -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.") diff --git a/ar-aging-app/backend/app/api/routes/controls.py b/ar-aging-app/backend/app/api/routes/controls.py new file mode 100644 index 0000000..3213ff8 --- /dev/null +++ b/ar-aging-app/backend/app/api/routes/controls.py @@ -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 diff --git a/ar-aging-app/backend/app/api/routes/export.py b/ar-aging-app/backend/app/api/routes/export.py index 269a984..b8345f2 100644 --- a/ar-aging-app/backend/app/api/routes/export.py +++ b/ar-aging-app/backend/app/api/routes/export.py @@ -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) diff --git a/ar-aging-app/backend/app/api/routes/payouts.py b/ar-aging-app/backend/app/api/routes/payouts.py new file mode 100644 index 0000000..b9d94e6 --- /dev/null +++ b/ar-aging-app/backend/app/api/routes/payouts.py @@ -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)} diff --git a/ar-aging-app/backend/app/api/routes/results.py b/ar-aging-app/backend/app/api/routes/results.py index 079bde6..41a20fd 100644 --- a/ar-aging-app/backend/app/api/routes/results.py +++ b/ar-aging-app/backend/app/api/routes/results.py @@ -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")} diff --git a/ar-aging-app/backend/app/api/routes/settings.py b/ar-aging-app/backend/app/api/routes/settings.py index 9a30fc2..6953edc 100644 --- a/ar-aging-app/backend/app/api/routes/settings.py +++ b/ar-aging-app/backend/app/api/routes/settings.py @@ -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) diff --git a/ar-aging-app/backend/app/core/calamine_reader.py b/ar-aging-app/backend/app/core/calamine_reader.py index 10da112..38dd85a 100644 --- a/ar-aging-app/backend/app/core/calamine_reader.py +++ b/ar-aging-app/backend/app/core/calamine_reader.py @@ -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 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. diff --git a/ar-aging-app/backend/app/core/column_map.py b/ar-aging-app/backend/app/core/column_map.py index 9ecbcb9..d1c960d 100644 --- a/ar-aging-app/backend/app/core/column_map.py +++ b/ar-aging-app/backend/app/core/column_map.py @@ -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] diff --git a/ar-aging-app/backend/app/core/controls.py b/ar-aging-app/backend/app/core/controls.py new file mode 100644 index 0000000..07ef1d8 --- /dev/null +++ b/ar-aging-app/backend/app/core/controls.py @@ -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 , 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) diff --git a/ar-aging-app/backend/app/core/definitions.py b/ar-aging-app/backend/app/core/definitions.py new file mode 100644 index 0000000..3072dc0 --- /dev/null +++ b/ar-aging-app/backend/app/core/definitions.py @@ -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)", + }, +} diff --git a/ar-aging-app/backend/app/core/i18n.py b/ar-aging-app/backend/app/core/i18n.py index 1b54650..97c2028 100644 --- a/ar-aging-app/backend/app/core/i18n.py +++ b/ar-aging-app/backend/app/core/i18n.py @@ -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) diff --git a/ar-aging-app/backend/app/core/journal.py b/ar-aging-app/backend/app/core/journal.py index ceb47f8..7cc162d 100644 --- a/ar-aging-app/backend/app/core/journal.py +++ b/ar-aging-app/backend/app/core/journal.py @@ -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) diff --git a/ar-aging-app/backend/app/core/money.py b/ar-aging-app/backend/app/core/money.py new file mode 100644 index 0000000..be7ced0 --- /dev/null +++ b/ar-aging-app/backend/app/core/money.py @@ -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) diff --git a/ar-aging-app/backend/app/core/pipeline.py b/ar-aging-app/backend/app/core/pipeline.py index 60e443b..c55154d 100644 --- a/ar-aging-app/backend/app/core/pipeline.py +++ b/ar-aging-app/backend/app/core/pipeline.py @@ -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) diff --git a/ar-aging-app/backend/app/core/reconciliation.py b/ar-aging-app/backend/app/core/reconciliation.py index b8fe0f4..f1cba72 100644 --- a/ar-aging-app/backend/app/core/reconciliation.py +++ b/ar-aging-app/backend/app/core/reconciliation.py @@ -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 diff --git a/ar-aging-app/backend/app/core/settlements.py b/ar-aging-app/backend/app/core/settlements.py index 45932b6..4ea3254 100644 --- a/ar-aging-app/backend/app/core/settlements.py +++ b/ar-aging-app/backend/app/core/settlements.py @@ -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) diff --git a/ar-aging-app/backend/app/core/summary_export.py b/ar-aging-app/backend/app/core/summary_export.py index 4439eab..c611085 100644 --- a/ar-aging-app/backend/app/core/summary_export.py +++ b/ar-aging-app/backend/app/core/summary_export.py @@ -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 diff --git a/ar-aging-app/backend/app/core/xlsx_reader.py b/ar-aging-app/backend/app/core/xlsx_reader.py index 61de497..9a7607a 100644 --- a/ar-aging-app/backend/app/core/xlsx_reader.py +++ b/ar-aging-app/backend/app/core/xlsx_reader.py @@ -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 /the last — 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 . + 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' 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 diff --git a/ar-aging-app/backend/app/db/database.py b/ar-aging-app/backend/app/db/database.py index 820f31e..b7b9ce2 100644 --- a/ar-aging-app/backend/app/db/database.py +++ b/ar-aging-app/backend/app/db/database.py @@ -45,6 +45,25 @@ def _migrate() -> None: ("eta_seconds", "INTEGER DEFAULT 0"), ("opening_mode", "VARCHAR 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"), diff --git a/ar-aging-app/backend/app/db/models.py b/ar-aging-app/backend/app/db/models.py index bc57e74..3645c81 100644 --- a/ar-aging-app/backend/app/db/models.py +++ b/ar-aging-app/backend/app/db/models.py @@ -30,7 +30,18 @@ class Session(Base): # How the opening AR balance is established: zero (default) | carry_forward | manual opening_mode = Column(String, default="zero") opening_source_session_id = Column(Integer) - status = Column(String, 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, 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, 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, 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) status = Column(String, 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, 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, default="") + confirmed_at = Column(DateTime) + confirmed_month = Column(String, 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, nullable=False) + account_type = Column(String, nullable=False) # bucket label, e.g. "(unspecified)" + settlement_id = Column(String, nullable=False) + bank_date = Column(Date, nullable=False) + bank_amount = Column(Float) # optional; None = same as Amazon amount + note = Column(String, default="") + entered_by = Column(String, 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) # "C1".."C6" + label = Column(String) + status = Column(String) # pass|fail|not_applicable + severity = Column(String, 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, 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, default="") + reviewed_at = Column(DateTime) + approved_by = Column(String, default="") + approved_at = Column(DateTime) class ExportRecord(Base): diff --git a/ar-aging-app/backend/app/services/controls_run.py b/ar-aging-app/backend/app/services/controls_run.py new file mode 100644 index 0000000..fa2321a --- /dev/null +++ b/ar-aging-app/backend/app/services/controls_run.py @@ -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), + } diff --git a/ar-aging-app/backend/app/services/jobs.py b/ar-aging-app/backend/app/services/jobs.py index 373cc0a..cf3f326 100644 --- a/ar-aging-app/backend/app/services/jobs.py +++ b/ar-aging-app/backend/app/services/jobs.py @@ -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) diff --git a/ar-aging-app/backend/app/services/store.py b/ar-aging-app/backend/app/services/store.py index 4c7ef1f..a2218b8 100644 --- a/ar-aging-app/backend/app/services/store.py +++ b/ar-aging-app/backend/app/services/store.py @@ -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 @@ -45,7 +45,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, )) @@ -98,7 +102,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() @@ -110,7 +115,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, ) @@ -173,12 +178,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, @@ -186,11 +206,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) @@ -261,6 +281,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", diff --git a/ar-aging-app/backend/requirements.txt b/ar-aging-app/backend/requirements.txt index baf1713..2ecce35 100644 --- a/ar-aging-app/backend/requirements.txt +++ b/ar-aging-app/backend/requirements.txt @@ -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 diff --git a/ar-aging-app/backend/tests/conftest.py b/ar-aging-app/backend/tests/conftest.py index ca0730d..2ad9088 100644 --- a/ar-aging-app/backend/tests/conftest.py +++ b/ar-aging-app/backend/tests/conftest.py @@ -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 diff --git a/ar-aging-app/backend/tests/test_aging.py b/ar-aging-app/backend/tests/test_aging.py new file mode 100644 index 0000000..e5f5d4d --- /dev/null +++ b/ar-aging-app/backend/tests/test_aging.py @@ -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) diff --git a/ar-aging-app/backend/tests/test_controls.py b/ar-aging-app/backend/tests/test_controls.py new file mode 100644 index 0000000..0bad6bb --- /dev/null +++ b/ar-aging-app/backend/tests/test_controls.py @@ -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" diff --git a/ar-aging-app/backend/tests/test_payout_receipts.py b/ar-aging-app/backend/tests/test_payout_receipts.py new file mode 100644 index 0000000..4d4683f --- /dev/null +++ b/ar-aging-app/backend/tests/test_payout_receipts.py @@ -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 diff --git a/ar-aging-app/backend/tests/test_per_market.py b/ar-aging-app/backend/tests/test_per_market.py index 193cea8..3a17ac4 100644 --- a/ar-aging-app/backend/tests/test_per_market.py +++ b/ar-aging-app/backend/tests/test_per_market.py @@ -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() diff --git a/ar-aging-app/backend/tests/test_reader_equivalence.py b/ar-aging-app/backend/tests/test_reader_equivalence.py new file mode 100644 index 0000000..68c145a --- /dev/null +++ b/ar-aging-app/backend/tests/test_reader_equivalence.py @@ -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 `` 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() diff --git a/ar-aging-app/backend/tests/test_session_lifecycle.py b/ar-aging-app/backend/tests/test_session_lifecycle.py index 5e26ac0..be40523 100644 --- a/ar-aging-app/backend/tests/test_session_lifecycle.py +++ b/ar-aging-app/backend/tests/test_session_lifecycle.py @@ -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 diff --git a/ar-aging-app/backend/tests/test_summary_export.py b/ar-aging-app/backend/tests/test_summary_export.py index 40e9972..f3508f2 100644 --- a/ar-aging-app/backend/tests/test_summary_export.py +++ b/ar-aging-app/backend/tests/test_summary_export.py @@ -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): diff --git a/ar-aging-app/docs/AUDIT-REPORT.md b/ar-aging-app/docs/AUDIT-REPORT.md new file mode 100644 index 0000000..e133583 --- /dev/null +++ b/ar-aging-app/docs/AUDIT-REPORT.md @@ -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 `` | 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).* diff --git a/ar-aging-app/docs/SYSTEM-GUIDE.md b/ar-aging-app/docs/SYSTEM-GUIDE.md index f3f61f9..b856b69 100644 --- a/ar-aging-app/docs/SYSTEM-GUIDE.md +++ b/ar-aging-app/docs/SYSTEM-GUIDE.md @@ -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 `` | 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 diff --git a/ar-aging-app/frontend/src/App.tsx b/ar-aging-app/frontend/src/App.tsx index 0e9d370..093c48a 100644 --- a/ar-aging-app/frontend/src/App.tsx +++ b/ar-aging-app/frontend/src/App.tsx @@ -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() { @@ -53,6 +55,7 @@ export default function App() {
} /> + } /> } /> } /> } /> diff --git a/ar-aging-app/frontend/src/api/client.ts b/ar-aging-app/frontend/src/api/client.ts index c228af9..ae9e8d1 100644 --- a/ar-aging-app/frontend/src/api/client.ts +++ b/ar-aging-app/frontend/src/api/client.ts @@ -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; 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(`/mapping-rules/${ruleId}`, { method: "DELETE" }), reconciliation: (id: number) => req(`/sessions/${id}/reconciliation`), aging: (id: number) => - req<{ bands: string[]; rows: Record[] }>(`/sessions/${id}/aging`), - journal: (id: number) => req(`/sessions/${id}/journal`), + req[] }>( + `/sessions/${id}/aging`), + journal: (id: number, marketplace?: string) => + req(`/sessions/${id}/journal${marketplace ? `?marketplace=${encodeURIComponent(marketplace)}` : ""}`), + reviewJournal: (id: number, name: string) => + req(`/sessions/${id}/journal/review`, { method: "POST", body: JSON.stringify({ name }) }), + approveJournal: (id: number, name: string) => + req(`/sessions/${id}/journal/approve`, { method: "POST", body: JSON.stringify({ name }) }), + resetJournalSignoff: (id: number) => + req(`/sessions/${id}/journal/reset-signoff`, { method: "POST" }), + accountsSummary: () => req("/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(`/sessions/${id}/opening-balances`), + openingWorksheet: (id: number) => + req(`/sessions/${id}/opening-balances/worksheet`), putOpenings: (id: number, items: OpeningBalanceT[]) => req(`/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>("/definitions"), + + payouts: (id: number, marketplace?: string) => + req(`/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(`/sessions/${id}/controls`), + runControls: (id: number) => req(`/sessions/${id}/controls/run`, { method: "POST" }), + confirmAllFx: (id: number, confirmed_by: string) => + req(`/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 }[]) => diff --git a/ar-aging-app/frontend/src/components/BankReceipts.tsx b/ar-aging-app/frontend/src/components/BankReceipts.tsx new file mode 100644 index 0000000..2d7a91d --- /dev/null +++ b/ar-aging-app/frontend/src/components/BankReceipts.tsx @@ -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>({}); + 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 ( +
+ + + + } + > + {data.needs_reprocess && ( +
+ + + Receipt entries changed — re-process the closing to apply them to the + receivable and the ledger. The dates below already preview the effect. + + +
+ )} + +
+ + + {!marketplace && } + + + + + + + + + + {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 ( + + {!marketplace && } + + + + + + + + + ); + })} + +
MarketplaceSettlementStreamAmazon dateAmazon amountBank received dateBank amount (optional)Status
{p.marketplace}{p.settlement_id} + {p.account_type === "(unspecified)" ? "—" : p.account_type}{fmtDate(p.amazon_date)}{acct(p.amount)} + setDrafts((s) => ({ ...s, [k]: { d: e.target.value, a: bankAmt } }))} /> + + setDrafts((s) => ({ ...s, [k]: { d: bankDate, a: e.target.value } }))} /> + + + {willReceive ? : } + {willReceive ? "received" : "in transit"} + + {bankDate && bank-dated} +
+
+ + {dirty && ( +
+ + + {Object.keys(drafts).length} payout(s) edited — saving marks the closing for re-processing. + + + + {save.isError && {(save.error as Error).message}} +
+ )} +
+ ); +} + +const key = (p: PayoutT) => `${p.account_type}|${p.settlement_id}|${p.marketplace}`; diff --git a/ar-aging-app/frontend/src/components/ui.tsx b/ar-aging-app/frontend/src/components/ui.tsx index 8ed108a..2353126 100644 --- a/ar-aging-app/frontend/src/components/ui.tsx +++ b/ar-aging-app/frontend/src/components/ui.tsx @@ -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 { + const { data } = useQuery({ + queryKey: ["definitions"], queryFn: api.definitions, + staleTime: Infinity, gcTime: Infinity, + }); + return data ?? {}; +} export function Spinner({ className = "" }: { className?: string }) { return ; @@ -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 ( + + + {open && ( + e.stopPropagation()}> + {label && {label}} + + {def.formula} + + + From: {def.source} + + {def.note && ( + + Note: {def.note} + + )} + + )} + + ); +} + +/** + * 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 ( +
+ +
+

Figures withheld

+

+ 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{" "} + + Controls tab + {" "} + and re-run. +

+
+
+ ); +} + export function EmptyState({ title, hint, action }: { title: string; hint?: string; action?: ReactNode }) { return (
diff --git a/ar-aging-app/frontend/src/pages/AccountsSummary.tsx b/ar-aging-app/frontend/src/pages/AccountsSummary.tsx new file mode 100644 index 0000000..22e700c --- /dev/null +++ b/ar-aging-app/frontend/src/pages/AccountsSummary.tsx @@ -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
Loading…
; + if (!data?.available) + return ( +
+
+ +
+ ); + + 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 ( +
+
+ +
+ {[ALL, ...data.marketplaces].map((m) => ( + + ))} +
+ +
+
+ + + + + {months.map((m) => ( + + ))} + + + + {rowKeys.map((k) => { + const isRec = k === data.receivable_key; + return ( + + + {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 ( + + ); + })} + + ); + })} + + + {months.map((m) => ( + + ))} + + +
Line + {m.month} + + {m.entry_no ? `JE ${m.entry_no}` : m.session_name} + +
+ {isRec ? "Receivable (Dr A/R)" : k} + + {v ? acct(v) : "0.00"} +
Approved by + + {m.approved_by} + + open closing → +
+
+
+ +

+ {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. +

+
+ ); +} + +function Header() { + return ( +
+

+ Accounts Summary +

+

+ Approved month-end journal entries — every month and marketplace side by side. +

+
+ ); +} diff --git a/ar-aging-app/frontend/src/pages/Closing.tsx b/ar-aging-app/frontend/src/pages/Closing.tsx index 071f41d..2025303 100644 --- a/ar-aging-app/frontend/src/pages/Closing.tsx +++ b/ar-aging-app/frontend/src/pages/Closing.tsx @@ -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(); 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 ( + + ); +} + export default function Closing() { const { id } = useParams(); const sid = Number(id); @@ -40,7 +58,10 @@ export default function Closing() { if (isLoading || !session) return
Loading closing…
; - 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 (
@@ -80,11 +101,39 @@ export default function Closing() {
{session.error}
)} + {session.needs_reprocess && session.status !== "processing" && ( +
+
+ + + Bank receipts or the payout mode changed after the last run — the figures on + screen don't reflect them yet. Re-process to apply. + + +
+
+ )} + {session.blocked && ( +
+
+ +
+

+ Blocked by a month-end control — no receivable figure is published +

+

{session.blocked_reason}

+
+ Open Controls +
+
+ )}
}> } /> + } /> + } /> } /> } /> } /> diff --git a/ar-aging-app/frontend/src/pages/Dashboard.tsx b/ar-aging-app/frontend/src/pages/Dashboard.tsx index 9b85ffc..de092d5 100644 --- a/ar-aging-app/frontend/src/pages/Dashboard.tsx +++ b/ar-aging-app/frontend/src/pages/Dashboard.tsx @@ -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 blocked; return {usd(data?.closing_receivable_usd)}; } diff --git a/ar-aging-app/frontend/src/pages/closing/Aging.tsx b/ar-aging-app/frontend/src/pages/closing/Aging.tsx index f9fa0e9..4535b18 100644 --- a/ar-aging-app/frontend/src/pages/closing/Aging.tsx +++ b/ar-aging-app/frontend/src/pages/closing/Aging.tsx @@ -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 ; - if (!data) return null; + if (data?.blocked) return ; + if (!data?.rows) return null; const chart = data.rows.map((r) => ({ name: String(r.marketplace), Current: Number(r.Current) || 0 })); return (
+ subtitle="Banded by days past due at month-end — a settlement is due 14 days after its last activity plus the clearing lag." + actions={}>
diff --git a/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx b/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx index b81c594..c6605c3 100644 --- a/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx +++ b/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx @@ -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() {
-
+
}>
- - + Opening AR balance} + v={mv.opening!} cur={cur} /> + + Net revenue (accrued)} + v={mv.net_revenue!} cur={cur} pos />
- + − Amazon payouts received} + v={mv.received_payouts!} cur={cur} />
= Closing receivable @@ -97,6 +103,7 @@ export default function ArLedger() {

In-transit payouts of {m(mv.in_transit_payouts)} remain in receivable (not yet cleared). +

@@ -138,6 +145,10 @@ export default function ArLedger() {
+ {/* Bank receipts: when each payout actually reached the bank (drives received + vs in-transit and the Movement-by-date placement below). */} + +
@@ -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 (
diff --git a/ar-aging-app/frontend/src/pages/closing/Controls.tsx b/ar-aging-app/frontend/src/pages/closing/Controls.tsx new file mode 100644 index 0000000..c6027d2 --- /dev/null +++ b/ar-aging-app/frontend/src/pages/closing/Controls.tsx @@ -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
Loading controls…
; + if (!data?.available) + return ; + + const fxFailing = data.controls.some((r) => r.key === "C5" && r.status === "fail"); + + return ( +
+
+ + {data.blocked ? : } + +
+
+ {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})`} +
+

+ {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."} +

+
+ +
+ + {fxFailing && ( +
+
+ + +

+ Review the rates on the Settings tab first — confirming records who accepted them and when. +

+
+ {confirmFx.isError && ( +

{(confirmFx.error as Error).message}

+ )} +
+ )} + +
+
    + {data.controls.map((r) => { + const { Icon, cls, bg, border } = tone(r); + return ( +
  • +
    + +
    +
    + {r.key} + {r.label} + {r.status === "fail" && ( + + {r.severity === "error" ? "blocking" : "review"} + + )} +
    +

    {r.detail}

    + {r.evidence.length > 0 && ( +
      + {r.evidence.map((e, i) => ( +
    • {e}
    • + ))} +
    + )} +
    +
    +
  • + ); + })} +
+
+
+ ); +} diff --git a/ar-aging-app/frontend/src/pages/closing/FinanceSummary.tsx b/ar-aging-app/frontend/src/pages/closing/FinanceSummary.tsx index cfd44b5..85759b1 100644 --- a/ar-aging-app/frontend/src/pages/closing/FinanceSummary.tsx +++ b/ar-aging-app/frontend/src/pages/closing/FinanceSummary.tsx @@ -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 ; + if (data?.blocked) return ; if (!data?.available) return ; const cur = data.currency ?? "USD"; @@ -39,14 +41,14 @@ export default function FinanceSummary() { const Row = ({ c }: { c: ComponentT }) => (
- + {c.values.map((v, i) => )} ); - const SubTotal = ({ label, v }: { label: string; v: number | undefined }) => ( + const SubTotal = ({ label, defKey, v }: { label: string; defKey: string; v: number | undefined }) => ( - + {periods.map((_, i) => @@ -93,14 +95,15 @@ export default function FinanceSummary() { - + {periods.map((_, i) => {rev.map((c) => )} - + {fees.map((c) => )} - + {memo.length > 0 && ( @@ -114,23 +117,27 @@ export default function FinanceSummary() {
- {[ - ["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]) => ( -
- {label} + {([ + ["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]) => ( +
+ + {label}{defKey && } + {m(v as number, 2)}
))}
- = Closing receivable + = Closing receivable + {m(data.closing_receivable)}

In-transit payouts {m(data.in_transit_payouts)} stay in receivable. +

diff --git a/ar-aging-app/frontend/src/pages/closing/JournalEntry.tsx b/ar-aging-app/frontend/src/pages/closing/JournalEntry.tsx index a617043..ee5bc72 100644 --- a/ar-aging-app/frontend/src/pages/closing/JournalEntry.tsx +++ b/ar-aging-app/frontend/src/pages/closing/JournalEntry.tsx @@ -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 ( - - {acct(v)} - - ); +/** + * 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 ; - 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 (
Journal Entry
-
Amazon {data.marketplace} — month-end
+
+ {showAll ? "All marketplaces — month-end" : `Amazon ${data.marketplace} — month-end`} +
@@ -48,48 +76,237 @@ export default function JournalEntry() { onBlur={() => entryNo !== (data.entry_no ?? "") && saveNo.mutate(entryNo)} />

- Every Amazon transaction column is booked to a GL account per 10‑day period; the - Receivable is the balancing figure (−net of all lines) and equals the amount posted to A/R. + The month-end accrual 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. +

+ {multi && }
-
-
-
{c.label}{c.label}
{label}{label})} {m(v, 2)}
Opening AR balanceOpening AR balance + )} {m(data.opening_balance, 2)}
Memo — already included above
- - - - {periods.map((p) => )} - - - - - - {lines.map((ln) => ( - - - {ln.values.map((v, i) => )} - - - - ))} - - - {rec.values.map((v, i) => )} - - - - -
{periods[0]?.label?.split(" ").slice(-2).join(" ") ?? "Line"}{p.label}TotalGL Account
{ln.key}{ln.gl_account}
Receivable{rec.gl_account}
-
-
+ {showAll + ? + : } -

- 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). -

+
); } + +/* ------------------------------------------------ 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 ( +
+
+ + + + {showPeriods && periods.map((p) => )} + + + + + + {lines.map((ln) => )} + {rec && } + + + {showPeriods && periods.map((p) => + + + + +
Line{p.label}DebitCreditGL Account
Totals)} + {acct(totDr)}{acct(totCr)} + {Math.abs(totDr - totCr) < 0.02 + ? + balanced + : out of balance {acct(totDr - totCr)}} +
+
+
+ ); +} + +function Row({ ln, showPeriods, nPeriods, def, highlight }: { + ln: JournalLineT; showPeriods: boolean; nPeriods: number; + def?: { formula: string; source: string; note?: string }; highlight?: boolean; +}) { + return ( + + + {ln.key} + + {showPeriods && Array.from({ length: nPeriods }, (_, i) => ( + + {ln.values[i] != null ? acct(ln.values[i]) : ""} + + ))} + + {dr(ln.total) ? acct(dr(ln.total)) : ""} + + {cr(ln.total) ? acct(cr(ln.total)) : ""} + {ln.gl_account} + + ); +} + +/* -------------------------------------------- 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
Loading…
; + + 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 = {}; + 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 ( +
+
+ + + + {markets.map((m) => )} + + + + {keys.map((k) => ( + + + {markets.map((m) => )} + + + ))} + + + {markets.map((m) => )} + + + +
Line{m} + {cur(m)}Total (USD)
{k} + {per[m] ? acct(local(m, k)) : "—"}{acct(usdTotal(k))}
Receivable (Dr A/R) + {per[m] ? acct(recOf(m)) : "—"} + {acct(markets.reduce((s, m) => s + recOf(m) * rate(m), 0))} +
+
+
+ ); +} + +/* ------------------------------------------------------- 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 ( +
+
+
+
+ + 1 · Reviewed by +
+ {reviewed ? ( +

+ {j.reviewed_by} + {fmtDate(j.reviewed_at)} +

+ ) : ( +
+ setReviewer(e.target.value)} /> + +
+ )} + {review.isError &&

{(review.error as Error).message}

} +
+ +
+
+ + 2 · Approved by +
+ {approved ? ( +

+ {j.approved_by} + {fmtDate(j.approved_at)} + published to Accounts Summary +

+ ) : ( +
+ setApprover(e.target.value)} + disabled={!reviewed} /> + +
+ )} + {!reviewed && !approved && +

Requires a review first.

} + {approve.isError &&

{(approve.error as Error).message}

} +
+
+ {(reviewed || approved) && ( +
+ +
+ )} +
+ ); +} diff --git a/ar-aging-app/frontend/src/pages/closing/OpeningBalances.tsx b/ar-aging-app/frontend/src/pages/closing/OpeningBalances.tsx new file mode 100644 index 0000000..c011759 --- /dev/null +++ b/ar-aging-app/frontend/src/pages/closing/OpeningBalances.tsx @@ -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>({}); + const [reason, setReason] = useState(""); + const [srcId, setSrcId] = useState(); + 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 ; + if (isLoading) return
Loading…
; + if (!data?.available) return ; + + const dirty = Object.values(drafts).some((v) => v.trim() !== ""); + const priors = data.candidates ?? []; + + return ( +
+ {data.all_zero && ( +
+ +
+ Every opening balance is zero.{" "} + + 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. + +
+
+ )} + +
+ {priors.length > 0 && ( + <> + + + + )} + +
+ } + > +
+ + + + + + + + + + + + + + {data.rows.map((r) => setDrafts((d) => ({ ...d, [r.marketplace]: v }))} />)} + +
MarketplaceOpening AR balanceSource+ Net revenue− Payouts received= Roll-forwardSettlementVariance
+
+ + {dirty && ( +
+ + + + {save.isError &&

{(save.error as Error).message}

} +
+ )} + + +

+ 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. +

+
+ ); +} + +function Row({ r, draft, onDraft }: { + r: OpeningWorksheetRowT; draft: string; onDraft: (v: string) => void; +}) { + const edited = draft.trim() !== ""; + return ( + + {r.marketplace} + {r.currency} + + onDraft(e.target.value)} + /> + + + + + {r.source === "carried_forward" ? "carried forward" : r.source} + + + {acct(r.net_revenue)} + {acct(-r.payouts_received)} + {acct(r.roll_forward_closing)} + + {r.settlement_closing != null ? acct(r.settlement_closing) : "—"} + + + {r.variance != null ? acct(r.variance) : "—"} + + + {r.reconciled + ? + : + implied {r.implied_opening != null ? acct(r.implied_opening) : "—"} + } + + + ); +} diff --git a/ar-aging-app/frontend/src/pages/closing/Overview.tsx b/ar-aging-app/frontend/src/pages/closing/Overview.tsx index 3d3fe8e..8336c19 100644 --- a/ar-aging-app/frontend/src/pages/closing/Overview.tsx +++ b/ar-aging-app/frontend/src/pages/closing/Overview.tsx @@ -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 Go to Upload} />; 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 ; const exc = summary.exceptions_by_severity ?? {}; return (
- - } mono={false} - sub={`reserve ${usd(summary.reserve_total, 2)}`} /> + 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. */} + 0 ? "warn" : "ok"} + value={ + controls + ? + 0 ? "review" : "reconciled"} /> + + : + } + sub={controls ? `${controls.passed}/${controls.total} passed · reserve ${usd(summary.reserve_total, 2)}` + : `reserve ${usd(summary.reserve_total, 2)}`} />
- - - + Receivable orders + } + value={usd(summary.receivable_orders, 2)} /> + Paid orders (settled) + } + value={usd(summary.paid_orders, 2)} /> + Transfers / disbursements + } + value={usd(summary.transfers_total, 2)} />
diff --git a/ar-aging-app/frontend/src/pages/closing/Reconciliation.tsx b/ar-aging-app/frontend/src/pages/closing/Reconciliation.tsx index 4d57a57..b7f4422 100644 --- a/ar-aging-app/frontend/src/pages/closing/Reconciliation.tsx +++ b/ar-aging-app/frontend/src/pages/closing/Reconciliation.tsx @@ -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."> - {rows.map(([label, val]) => ( + {rows.map(([label, defKey, val]) => ( - + ))} - + diff --git a/start.command b/start.command new file mode 100755 index 0000000..5e33533 --- /dev/null +++ b/start.command @@ -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 -- 2.40.1
{label}{label} + {defKey && } {usd(val, 2)}
Final closing receivable (USD)Final closing receivable (USD) + {usd(data.final_receivable_usd, 2)}