Aging report: selectable band width (weekly / monthly / 6 months / yearly)
Deploy to S3 / deploy (push) Successful in 29s
Details
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
parent
04dfbcf0e6
commit
c2e1840f5b
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }) }),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue