Aging report: selectable band width (weekly / monthly / 6 months / yearly)
Deploy to S3 / deploy (push) Successful in 29s Details

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 <noreply@anthropic.com>
main
Talha Ahmed 2026-08-24 22:09:55 +05:00
parent 04dfbcf0e6
commit c2e1840f5b
5 changed files with 203 additions and 22 deletions

View File

@ -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")}

View File

@ -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]]:

View File

@ -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 {

View File

@ -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<MappingRulesT>(`/mapping-rules/${ruleId}`, { method: "DELETE" }),
reconciliation: (id: number) => req<ReconT>(`/sessions/${id}/reconciliation`),
aging: (id: number) =>
req<BlockableT & { bands: string[]; rows: Record<string, number | string>[] }>(
`/sessions/${id}/aging`),
aging: (id: number, scheme: AgingSchemeT = "monthly") =>
req<BlockableT & { bands: string[]; scheme?: string; rows: Record<string, number | string>[] }>(
`/sessions/${id}/aging?scheme=${scheme}`),
journal: (id: number, marketplace?: string) =>
req<JournalT>(`/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<PayoutImportT>(
`/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 }) }),

View File

@ -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<AgingSchemeT>("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 <EmptyState title="Process the closing to see the A/R aging." />;
if (data?.blocked) return <BlockedNotice reason={data.blocked_reason} />;
@ -20,7 +34,21 @@ export default function Aging() {
<div className="space-y-6">
<Section title="Accounts Receivable Aging"
subtitle="Banded by days past due at month-end — a settlement is due 14 days after its last activity plus the clearing lag."
actions={<InfoTip def={defs.aging_basis} label="How the aging bands work" />}>
actions={
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-line overflow-hidden text-xs">
{SCHEMES.map((s) => (
<button key={s.key}
className={`px-2.5 py-1.5 transition-colors ${
scheme === s.key ? "bg-primary text-white" : "bg-panel text-subink hover:text-ink"}`}
onClick={() => setScheme(s.key)}>
{s.label}
</button>
))}
</div>
<InfoTip def={defs.aging_basis} label="How the aging bands work" />
</div>
}>
<div className="overflow-auto">
<table className="w-full">
<thead><tr>