From c2e1840f5b6c384b6eb15b1732614bde3cd052ec Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Mon, 24 Aug 2026 22:09:55 +0500 Subject: [PATCH] Aging report: selectable band width (weekly / monthly / 6 months / yearly) GET /sessions/{id}/aging?scheme=... rebands the same days-past-due data; the Aging page gets a segmented filter and renders whatever bands the API returns. Totals tie to the headline receivable in every scheme. Also carries the alias-guard test for the reference-workbook headers. Co-Authored-By: Claude Fable 5 --- .../backend/app/api/routes/results.py | 16 ++-- ar-aging-app/backend/app/core/receivable.py | 39 ++++++--- .../backend/tests/test_engine_unit.py | 81 ++++++++++++++++++- ar-aging-app/frontend/src/api/client.ts | 55 ++++++++++++- .../frontend/src/pages/closing/Aging.tsx | 34 +++++++- 5 files changed, 203 insertions(+), 22 deletions(-) diff --git a/ar-aging-app/backend/app/api/routes/results.py b/ar-aging-app/backend/app/api/routes/results.py index 7e31672..dd451a9 100644 --- a/ar-aging-app/backend/app/api/routes/results.py +++ b/ar-aging-app/backend/app/api/routes/results.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request from sqlalchemy import func from sqlalchemy.orm import Session as OrmSession -from ...core.receivable import AGING_BANDS, classify_aging +from ...core.receivable import AGING_SCHEMES, aging_bands, classify_aging from ...core.settlements import RECEIVABLE_ACCOUNT_TYPES from ...db import models from ..auth import actor_name @@ -252,7 +252,8 @@ def reset_journal_signoff(session_id: int, db: OrmSession = Depends(db_dep)) -> @router.get("/{session_id}/aging") -def aging(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: +def aging(session_id: int, scheme: str = "monthly", + db: OrmSession = Depends(db_dep)) -> dict: """ Real aging, banded by days **past due** — not days since the transaction. @@ -267,9 +268,12 @@ def aging(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: 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.) """ + if scheme not in AGING_SCHEMES: + raise HTTPException(400, f"scheme must be one of: {', '.join(AGING_SCHEMES)}.") s = get_session_or_404(session_id, db) if is_blocked(s): return blocked_payload(s) + bands = aging_bands(scheme) rows = db.query(models.ReceivableResultRow).filter( models.ReceivableResultRow.session_id == session_id, models.ReceivableResultRow.account_type == "TOTAL").all() @@ -289,12 +293,12 @@ def aging(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: 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 + band = classify_aging(days_overdue, scheme) + by_mkt.setdefault(st.marketplace, {b: 0.0 for b in bands})[band] += st.order_total matrix = [] for r in rows: - local_bands = by_mkt.get(r.marketplace) or {b: 0.0 for b in AGING_BANDS} + local_bands = by_mkt.get(r.marketplace) or {b: 0.0 for b in 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 @@ -306,7 +310,7 @@ def aging(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: 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, + return {"bands": list(bands), "scheme": scheme, "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/core/receivable.py b/ar-aging-app/backend/app/core/receivable.py index edf0504..6f0fe7f 100644 --- a/ar-aging-app/backend/app/core/receivable.py +++ b/ar-aging-app/backend/app/core/receivable.py @@ -23,6 +23,27 @@ from .settlements import AggregationResult, Classification, RECEIVABLE_ACCOUNT_T # month-end it is always "Current"; the day-based bands are kept for completeness. AGING_BANDS = ("Current", "1-30", "31-60", "61-90", "91-Over") +# Selectable band widths for the aging report. Each value lists the inclusive upper edge +# (in days past due) of every closed band; whatever exceeds the last edge falls into the +# open-ended "-Over" band. "monthly" reproduces AGING_BANDS. +AGING_SCHEMES: dict[str, tuple[int, ...]] = { + "weekly": (7, 14, 21, 28), + "monthly": (30, 60, 90), + "half_year": (180, 360, 540), + "yearly": (365, 730, 1095), +} + + +def aging_bands(scheme: str = "monthly") -> tuple[str, ...]: + """Band labels for a scheme, e.g. monthly -> Current, 1-30, 31-60, 61-90, 91-Over.""" + edges = AGING_SCHEMES.get(scheme, AGING_SCHEMES["monthly"]) + labels, lo = ["Current"], 1 + for e in edges: + labels.append(f"{lo}-{e}") + lo = e + 1 + labels.append(f"{lo}-Over") + return tuple(labels) + @dataclass class AccountReceivable: @@ -142,17 +163,17 @@ def compute_receivable( return result -def classify_aging(days_outstanding: int | None) -> str: - """Day-based aging band (kept for completeness; Amazon receivable is 'Current').""" +def classify_aging(days_outstanding: int | None, scheme: str = "monthly") -> str: + """Day-based aging band for the scheme (default matches the classic monthly bands).""" if days_outstanding is None or days_outstanding <= 0: return "Current" - if days_outstanding <= 30: - return "1-30" - if days_outstanding <= 60: - return "31-60" - if days_outstanding <= 90: - return "61-90" - return "91-Over" + edges = AGING_SCHEMES.get(scheme, AGING_SCHEMES["monthly"]) + lo = 1 + for e in edges: + if days_outstanding <= e: + return f"{lo}-{e}" + lo = e + 1 + return f"{lo}-Over" def aging_summary(result: ReceivableResult, band: str = "Current") -> dict[str, dict[str, float]]: diff --git a/ar-aging-app/backend/tests/test_engine_unit.py b/ar-aging-app/backend/tests/test_engine_unit.py index 90cf9cb..e6e1989 100644 --- a/ar-aging-app/backend/tests/test_engine_unit.py +++ b/ar-aging-app/backend/tests/test_engine_unit.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import date from app.core.dates import parse_amazon_date, parse_amazon_datetime -from app.core.column_map import build_mapping, normalize_header +from app.core.column_map import build_mapping, normalize_header, resolve_field from app.core.settlements import aggregate, classify from app.core.receivable import compute_receivable, classify_aging @@ -40,6 +40,67 @@ def test_column_mapping_missing_required(): assert "total" in m.missing_required +def test_reference_workbook_aliases_resolve(): + """Every header variant from the finance team's per-marketplace reference workbook + must resolve. Doubles as a collision guard: _ALIAS_TO_FIELD is first-wins, so an + alias later hijacked by an earlier field makes the expected mapping here fail.""" + expected = [ + # promotional rebates family + ("Promotional Discounts", "promotional_rebates"), + ("Total Discounts", "promotional_rebates"), + ("promosyon indirimleri", "promotional_rebates"), + ("Tax on Promotional Discounts", "promotional_rebates_tax"), + # shipping credits family + ("Shipping Credit", "shipping_credits"), + ("kargo kredileri", "shipping_credits"), + ("Tax on Shipping Credit", "shipping_credits_tax"), + ("Tax on Shipping Credits", "shipping_credits_tax"), + # gift wrap family + ("Gift Wrap Credit", "gift_wrap_credits"), + ("Tax on Gift Wrap Credit", "giftwrap_credits_tax"), + ("Tax on Gift Wrap Credits", "giftwrap_credits_tax"), + # other amount columns + ("Marketplace Withheld VAT", "marketplace_withheld_tax"), + ("ürün satışları", "product_sales"), + ("satış ücretleri", "selling_fees"), + ("Amazon Lojistik ücretleri", "fba_fees"), + ("diğer işlem ücretleri", "other_transaction_fees"), + ("diğer", "other"), + # transaction release date translations + ("Freigabedatum der Transaktion", "transaction_release_date"), + ("Date de sortie de la transaction", "transaction_release_date"), + ("Data di rilascio della transazione", "transaction_release_date"), + ("Fecha de liberación de la transacción", "transaction_release_date"), + ("Publicatiedatum van transactie", "transaction_release_date"), + ("Data zrealizowania transakcji", "transaction_release_date"), + ("Transaktionens utgivningsdatum", "transaction_release_date"), + ("İşlem çıkış tarihi", "transaction_release_date"), + # transaction status translations + ("Transactiestatus", "transaction_status"), + ("Status transakcji", "transaction_status"), + ("İşlem durumu", "transaction_status"), + # location / fulfillment variants + ("Order State/Province", "order_state"), + ("State/Province", "order_state"), + ("Order Region/Province", "order_state"), + ("Order Province/State", "order_state"), + ("Order Region/Autonomous Community", "order_state"), + ("sipariş durumu", "order_state"), + ("Order Postal Code", "order_postal"), + ("sipariş postası", "order_postal"), + ("sipariş şehri", "order_city"), + ("Shipping/Fulfillment", "fulfillment"), + ("Fulfillment/Shipping", "fulfillment"), + ("gönderim", "fulfillment"), + ] + for header, want in expected: + assert resolve_field(header) == want, f"{header!r} -> {resolve_field(header)!r}, want {want!r}" + # Pre-existing aliases that must not be hijacked by the additions above. + assert resolve_field("shipping") == "shipping_credits" + assert resolve_field("Transaktionsstatus") == "transaction_status" + assert resolve_field("total des réductions") == "promotional_rebates" + + # --------------------------------------------------------------------------- aging def test_aging_bands(): assert classify_aging(None) == "Current" @@ -50,6 +111,24 @@ def test_aging_bands(): assert classify_aging(120) == "91-Over" +def test_aging_band_schemes(): + from app.core.receivable import AGING_BANDS, aging_bands + + assert aging_bands("monthly") == AGING_BANDS # default stays the classic bands + assert aging_bands("weekly") == ("Current", "1-7", "8-14", "15-21", "22-28", "29-Over") + assert aging_bands("half_year") == ("Current", "1-180", "181-360", "361-540", "541-Over") + assert aging_bands("yearly") == ("Current", "1-365", "366-730", "731-1095", "1096-Over") + assert aging_bands("nonsense") == AGING_BANDS # unknown scheme falls back + + assert classify_aging(5, "weekly") == "1-7" + assert classify_aging(14, "weekly") == "8-14" + assert classify_aging(35, "weekly") == "29-Over" + assert classify_aging(120, "half_year") == "1-180" + assert classify_aging(400, "yearly") == "366-730" + assert classify_aging(2000, "yearly") == "1096-Over" + assert classify_aging(0, "weekly") == "Current" + + # --------------------------------------------------------------------------- settlements def _rec(total, ttype, acct, sid, d, mkt="USA"): return { diff --git a/ar-aging-app/frontend/src/api/client.ts b/ar-aging-app/frontend/src/api/client.ts index b7ae314..d4d0435 100644 --- a/ar-aging-app/frontend/src/api/client.ts +++ b/ar-aging-app/frontend/src/api/client.ts @@ -92,6 +92,9 @@ export interface PayoutT { received_next_run: boolean; } +/** Aging band width for the A/R aging report. */ +export type AgingSchemeT = "weekly" | "monthly" | "half_year" | "yearly"; + export interface PayoutsT { payout_mode: string; clearing_lag_days: number; @@ -100,6 +103,45 @@ export interface PayoutsT { payouts: PayoutT[]; } +/** One bank-file row auto-matched to a payout by the disbursements import. */ +export interface PayoutImportMatchT { + marketplace: string; + account_type: string; + settlement_id: string; + amazon_date: string | null; + amazon_amount: number; + bank_date: string; + bank_amount: number; + currency: string; + amount_checked: boolean; + delta: number | null; + bank_row: number; + already_had_receipt: boolean; + existing_bank_date: string | null; + note: string; +} + +export interface PayoutImportRowT { + bank_row: number; + party: string; + marketplace?: string; + bank_date: string; + amount: number; + reason?: string; + candidates?: { settlement_id: string; account_type: string; amazon_date: string | null; amount: number }[]; +} + +export interface PayoutImportT { + total_rows: number; + window_days: number; + matched: PayoutImportMatchT[]; + ambiguous: PayoutImportRowT[]; + unmatched_bank_rows: PayoutImportRowT[]; + unknown_party: PayoutImportRowT[]; + out_of_scope: number; + problems: string[]; +} + /** One month-end control (core/controls.py). Distinct from ControlRowT, which is a row of * the Finance reconciliation control sheet. */ export interface MonthEndControlT { @@ -553,9 +595,9 @@ export const api = { deleteMappingRule: (ruleId: number) => req(`/mapping-rules/${ruleId}`, { method: "DELETE" }), reconciliation: (id: number) => req(`/sessions/${id}/reconciliation`), - aging: (id: number) => - req[] }>( - `/sessions/${id}/aging`), + aging: (id: number, scheme: AgingSchemeT = "monthly") => + req[] }>( + `/sessions/${id}/aging?scheme=${scheme}`), journal: (id: number, marketplace?: string) => req(`/sessions/${id}/journal${marketplace ? `?marketplace=${encodeURIComponent(marketplace)}` : ""}`), reviewJournal: (id: number, name: string) => @@ -610,6 +652,13 @@ export const api = { 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) }), + importPayoutReceipts: (id: number, file: File, windowDays = 14) => { + const fd = new FormData(); + fd.append("file", file); + return req( + `/sessions/${id}/payouts/receipts/import?window_days=${windowDays}`, + { method: "POST", body: fd }); + }, putPayoutMode: (id: number, mode: "auto" | "manual") => req<{ payout_mode: string; needs_reprocess: boolean }>( `/sessions/${id}/payouts/mode`, { method: "PUT", body: JSON.stringify({ mode }) }), diff --git a/ar-aging-app/frontend/src/pages/closing/Aging.tsx b/ar-aging-app/frontend/src/pages/closing/Aging.tsx index 4535b18..39f304c 100644 --- a/ar-aging-app/frontend/src/pages/closing/Aging.tsx +++ b/ar-aging-app/frontend/src/pages/closing/Aging.tsx @@ -1,14 +1,28 @@ +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; -import { api } from "../../api/client"; +import { AgingSchemeT, api } from "../../api/client"; import { usd } from "../../lib/format"; import { BlockedNotice, InfoTip, Section, EmptyState, useDefinitions } from "../../components/ui"; import { useClosing } from "../Closing"; +const SCHEMES: { key: AgingSchemeT; label: string }[] = [ + { key: "weekly", label: "Weekly" }, + { key: "monthly", label: "Monthly" }, + { key: "half_year", label: "6 months" }, + { key: "yearly", label: "Yearly" }, +]; + export default function Aging() { const { id, processed } = useClosing(); const defs = useDefinitions(); - const { data } = useQuery({ queryKey: ["aging", id], queryFn: () => api.aging(id), enabled: processed }); + const [scheme, setScheme] = useState("monthly"); + const { data } = useQuery({ + queryKey: ["aging", id, scheme], + queryFn: () => api.aging(id, scheme), + enabled: processed, + placeholderData: (prev) => prev, // keep the table while the new bands load + }); if (!processed) return ; if (data?.blocked) return ; @@ -20,7 +34,21 @@ export default function Aging() {
}> + actions={ +
+
+ {SCHEMES.map((s) => ( + + ))} +
+ +
+ }>