Finance-Accounts/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx

483 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { ReactNode, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { ArrowDownRight, ArrowUpRight, CloudDownload, Pencil, Save, CalendarRange,
RotateCcw, CornerDownRight, Loader2 } from "lucide-react";
import { api, OpeningBalanceT } from "../../api/client";
import { money, acct, num } from "../../lib/format";
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";
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;
const [gran, setGran] = useState<Gran>("day");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const { data: mv } = useQuery({
queryKey: ["ar-movement", id, mktParam],
queryFn: () => api.arMovement(id, mktParam),
enabled: processed,
placeholderData: keepPreviousData,
});
const { data: openings } = useQuery({
queryKey: ["openings", id], queryFn: () => api.openings(id), enabled: processed,
});
const { data: detail } = useQuery({
queryKey: ["ledger-detail", id, mktParam, gran, from, to],
queryFn: () => api.ledgerDetail(id, mktParam, gran, from || undefined, to || undefined),
enabled: processed && !showAll,
placeholderData: keepPreviousData,
});
const { data: fx } = useQuery({
queryKey: ["fx-daily", id, mktParam, from, to],
queryFn: () => api.fxDaily(id, mktParam, from || undefined, to || undefined),
enabled: processed && !showAll,
placeholderData: keepPreviousData,
});
const { data: all } = useQuery({
queryKey: ["all-markets", id], queryFn: () => api.allMarkets(id), enabled: processed && showAll,
});
if (!processed) return <EmptyState title="Process the closing to see the AR ledger." />;
if (!mv || !mv.available) return <EmptyState title="AR movement not available yet." />;
const mkt = mv.marketplace ?? "USA";
const cur = mv.currency ?? "USD";
const m = (v: number | null | undefined, dp = 0) => money(v, cur, dp);
// USD-reporting marketplaces would just repeat every figure — show the pair only when
// the local currency actually differs.
const dual = cur !== "USD";
const inUsd = (v: number | null | undefined, dp = 2) =>
dual && v != null ? (
<div className="text-[11px] leading-tight text-subink">{money(v, "USD", dp)}</div>
) : null;
const opening = openings?.find((o) => o.marketplace === mkt);
const diff = mv.difference_vs_settlement ?? 0;
const reconciled = Math.abs(diff) < 1;
const multi = (mv.marketplaces?.length ?? 0) > 1;
return (
<div className="space-y-6">
{multi && (
<div className="flex items-center gap-3 flex-wrap">
<MarketTabs markets={mv.marketplaces} value={showAll ? ALL_MARKETS : mkt} onChange={setSel} />
<span className="text-xs text-subink">
{showAll ? "Every marketplace with the combined total."
: <>Opening balance, revenue and payouts below are for <b>{mkt}</b> only.</>}
</span>
</div>
)}
{showAll ? (
<AllMarketsTable data={all} onView={setSel} title="All markets — receivable movement" />
) : (
<>
<OpeningEditor id={id} mkt={mkt} cur={cur} opening={opening}
onSaved={() => {
qc.invalidateQueries({ queryKey: ["openings", id] });
qc.invalidateQueries({ queryKey: ["ar-movement", id] });
qc.invalidateQueries({ queryKey: ["ledger-detail", id] });
}} />
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
<div className="lg:col-span-2">
<Section title="Closing receivable" subtitle="Opening + Net Revenue Payouts received"
actions={<InfoTip def={defs.closing_receivable} label="Closing receivable (roll-forward)" />}>
<div className="p-4 space-y-2 text-sm">
<Row label={<>Opening AR balance<InfoTip def={defs.opening_balance} label="Opening AR balance" /></>}
v={mv.opening!} cur={cur} />
<Row label={<>+ Net revenue (accrued)<InfoTip def={defs.net_revenue} label="Net revenue" /></>}
v={mv.net_revenue!} cur={cur} pos />
<div className="border-t border-line my-1" />
<Row label="= Total Amazon receivable" v={mv.total_receivable!} cur={cur} bold />
<Row label={<> Amazon payouts received<InfoTip def={defs.disbursements} label="Payouts received" /></>}
v={mv.received_payouts!} cur={cur} />
<div className="border-t border-line my-1" />
<div className="flex items-center justify-between pt-1">
<span className="font-semibold text-primary">= Closing receivable</span>
<span className="num text-lg font-semibold text-primary">{m(mv.closing)}</span>
</div>
{dual && detail?.month_rate != null && (
<div className="flex items-center justify-between text-xs text-subink">
<span>in USD @ month rate {num(detail.month_rate, 6)}</span>
<span className="num font-medium">
{money((mv.closing ?? 0) * detail.month_rate, "USD", 2)}
</span>
</div>
)}
<p className="text-xs text-subink pt-2">
In-transit payouts of <span className="num">{m(mv.in_transit_payouts)}</span> remain
in receivable (not yet cleared).
<InfoTip def={defs.in_transit_payouts} label="In-transit payouts" />
</p>
</div>
</Section>
</div>
<div className="lg:col-span-3">
<Section title="AR Ledger"
subtitle="Monthly receivable movement — the summary report is generated from this ledger.">
<div className="overflow-x-auto">
<table className="w-full">
<thead><tr>
<th className="th">Period</th><th className="th">Description</th>
<th className="th text-right">Debit</th><th className="th text-right">Credit</th>
<th className="th text-right">Running AR</th>
</tr></thead>
<tbody>
{(mv.ledger ?? []).map((r, i) => {
const key = r.period === "Opening" || r.period === "Closing";
return (
<tr key={i} className={key ? "bg-neutralbg/50 font-medium" : ""}>
<td className="td">{r.period}</td>
<td className="td text-subink text-xs">
<span className="inline-flex items-center gap-1">
{r.debit != null && <ArrowUpRight size={12} className="text-primary" />}
{r.credit != null && <ArrowDownRight size={12} className="text-bad" />}
{r.description}
</span>
</td>
<td className="td text-right num">{r.debit != null ? acct(r.debit) : ""}</td>
<td className="td text-right num">{r.credit != null ? acct(r.credit) : ""}</td>
<td className="td text-right num font-medium">{m(r.balance)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Section>
</div>
</div>
{/* Bank receipts: when each payout actually reached the bank (drives received
vs in-transit and the Movement-by-date placement below). */}
<BankReceipts id={id} marketplace={mkt} />
<div className={`card p-4 flex flex-wrap items-center gap-4 ${reconciled ? "bg-okbg/40" : "bg-warnbg/40"}`}>
<StatusBadge status={reconciled ? "reconciled" : "review"} />
<div className="text-sm">
<b>Cross-check vs settlement method:</b> roll-forward closing {m(mv.closing)} vs settlement-based{" "}
{m(mv.settlement_closing ?? undefined)} {" "}
<span className="num">{reconciled ? "match" : `difference ${m(diff, 2)}`}</span>.
</div>
{!reconciled && (
<p className="text-xs text-subink flex-1 min-w-[200px]">
A difference usually means the opening balance or a reserve/timing item needs review.
</p>
)}
</div>
{/* ---------------- date-filtered movement ---------------- */}
<Section title="Movement by date"
subtitle={`Daily, weekly or monthly view of the same ledger. Unfiltered, the running balance ends at the closing receivable.${
dual ? " USD figures (grey) are converted at each transaction date's exchange rate." : ""}`}>
<div className="p-4 flex flex-wrap items-end gap-3 border-b border-line">
<div className="flex gap-1 p-1 rounded-xl bg-neutralbg">
{(["day", "week", "month"] as Gran[]).map((g) => (
<button key={g} onClick={() => setGran(g)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
gran === g ? "bg-panel text-primary shadow-card" : "text-subink hover:text-ink"
}`}>{g === "day" ? "Daily" : g === "week" ? "Weekly" : "Monthly"}</button>
))}
</div>
<div className="flex items-end gap-2">
<div>
<label className="label mb-1">From</label>
<input type="date" className="input w-40 py-1.5" value={from}
onChange={(e) => setFrom(e.target.value)} />
</div>
<div>
<label className="label mb-1">To</label>
<input type="date" className="input w-40 py-1.5" value={to}
onChange={(e) => setTo(e.target.value)} />
</div>
{(from || to) && (
<button className="btn-ghost py-1.5" onClick={() => { setFrom(""); setTo(""); }}>
Clear
</button>
)}
</div>
<div className="flex-1" />
<span className="text-xs text-subink inline-flex items-center gap-1">
<CalendarRange size={13} />
{detail?.filtered ? "Custom range — balance covers the filtered dates only" : "Full month"}
</span>
</div>
<div className="overflow-x-auto max-h-[460px]">
<table className="w-full">
<thead><tr>
<th className="th">Period</th>
<th className="th text-right">Rows</th>
<th className="th text-right">Net revenue</th>
<th className="th text-right">Payouts received</th>
<th className="th text-right">In transit</th>
<th className="th text-right">Running AR</th>
</tr></thead>
<tbody>
<tr className="bg-neutralbg/50 font-medium">
<td className="td">Opening</td>
<td className="td" /><td className="td" /><td className="td" /><td className="td" />
<td className="td text-right num">
{m(detail?.opening)}
{inUsd(detail?.opening_usd)}
</td>
</tr>
{(detail?.periods ?? []).map((p) => (
<tr key={p.key}>
<td className="td">{p.label}</td>
<td className="td text-right num text-xs text-subink">{p.rows.toLocaleString()}</td>
<td className="td text-right num">
{acct(p.revenue)}
{inUsd(p.revenue_usd)}
</td>
<td className="td text-right num text-bad">
{p.payouts_received ? acct(p.payouts_received) : ""}
{p.payouts_received ? inUsd(p.payouts_received_usd) : null}
</td>
<td className="td text-right num text-warn">
{p.payouts_in_transit ? acct(p.payouts_in_transit) : ""}
{p.payouts_in_transit ? inUsd(p.payouts_in_transit_usd) : null}
</td>
<td className="td text-right num font-medium">
{m(p.balance)}
{inUsd(p.balance_usd)}
</td>
</tr>
))}
<tr className="bg-primary-soft/50 font-semibold">
<td className="td text-primary">Closing</td>
<td className="td" /><td className="td" /><td className="td" /><td className="td" />
<td className="td text-right num text-primary">
{m(detail?.closing)}
{inUsd(detail?.closing_usd)}
</td>
</tr>
</tbody>
</table>
</div>
{detail && !detail.filtered && (
<p className="px-4 py-3 text-xs text-subink border-t border-line">
Ends at {m(detail.closing)} matches the closing receivable above. In-transit payouts of{" "}
{m(detail.in_transit_total)} are listed separately and stay inside the balance.
</p>
)}
</Section>
{/* ---------------- daily FX ---------------- */}
<Section title={`Daily exchange rates — ${mkt}`}
subtitle="Local value per day, the USD rate applied, and the USD equivalent."
actions={cur !== "USD" ? <FetchDailyRates id={id} mkt={mkt} /> : undefined}>
{cur === "USD" && (
<div className="px-4 pt-3 text-xs text-subink">
{mkt} reports in USD no conversion applied (rate 1.000000).
</div>
)}
<div className="overflow-x-auto max-h-[420px]">
<table className="w-full">
<thead><tr>
<th className="th">Date</th>
<th className="th text-right">Local ({fx?.currency ?? cur})</th>
<th className="th text-right">USD rate</th>
<th className="th text-right">USD value</th>
<th className="th">Rate source</th>
</tr></thead>
<tbody>
{(fx?.rows ?? []).map((r) => (
<tr key={r.date}>
<td className="td num text-xs">{r.date}</td>
<td className="td text-right num">{money(r.local, fx?.currency ?? cur, 2)}</td>
<td className="td text-right num text-xs">{num(r.rate, 6)}</td>
<td className="td text-right num">{money(r.usd, "USD", 2)}</td>
<td className="td text-xs text-subink">{r.source}</td>
</tr>
))}
<tr className="bg-neutralbg/50 font-semibold">
<td className="td">Total</td>
<td className="td text-right num">{money(fx?.total_local, fx?.currency ?? cur, 2)}</td>
<td className="td" />
<td className="td text-right num">{money(fx?.total_usd, "USD", 2)}</td>
<td className="td" />
</tr>
</tbody>
</table>
</div>
<p className="px-4 py-3 text-xs text-subink border-t border-line">
Daily rates are fetched from the FX provider automatically when the closing is
processed; each movement converts at the rate effective on its transaction date a
date without a fixing (weekend or holiday) uses the previous banking day&apos;s rate.
The month rate ({num(fx?.month_rate, 6)}) applies to the opening balance and any date
with no fetched rate. Every rate used is shown here so the conversion is auditable.
</p>
</Section>
<p className="text-xs text-subink">
Gross revenue {m(mv.gross_revenue, 2)} net revenue {m(mv.net_revenue, 2)} after refunds and
Amazon fees. See the <b>Journal Entry</b> tab for the full line-by-line breakdown.
</p>
</>
)}
</div>
);
}
/** Re-fetch the official (ECB via Frankfurter) daily rates for the closing's transaction
* dates. Processing already fetches them automatically — this button retries after an
* outage or replaces hand-entered overrides with official fixings. */
function FetchDailyRates({ id, mkt }: { id: number; mkt: string }) {
const qc = useQueryClient();
const { locked } = useClosing();
const fetchDaily = useMutation({
mutationFn: () => api.fetchFxDaily(id, { marketplace: mkt }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["fx-daily", id] }),
});
return (
<div className="flex items-center gap-2">
{fetchDaily.isSuccess && (
<span className="text-xs text-ok">
{fetchDaily.data.saved} daily rate(s) loaded ({fetchDaily.data.provider}).
</span>
)}
{fetchDaily.isError && (
<span className="text-xs text-bad">{(fetchDaily.error as Error).message}</span>
)}
<button className="btn-ghost" disabled={fetchDaily.isPending || locked}
title="Re-fetch the official daily rates for the closing's transaction dates. Hand-entered overrides are replaced for the fetched dates."
onClick={() => fetchDaily.mutate()}>
{fetchDaily.isPending ? <Loader2 size={15} className="animate-spin" /> : <CloudDownload size={15} />}
Fetch daily rates
</button>
</div>
);
}
function Row({ label, v, cur, bold, pos }: {
label: ReactNode; v: number; cur: string; bold?: boolean; pos?: boolean;
}) {
return (
<div className="flex items-center justify-between">
<span className={bold ? "font-semibold" : "text-subink"}>{label}</span>
<span className={`num ${bold ? "font-semibold" : ""} ${v < 0 ? "text-bad" : pos ? "text-ok" : "text-ink"}`}>
{money(v, cur, 2)}
</span>
</div>
);
}
function OpeningEditor({ id, mkt, cur, opening, onSaved }: {
id: number; mkt: string; cur: string; opening: OpeningBalanceT | undefined; onSaved: () => void;
}) {
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const [pickSource, setPickSource] = useState(false);
const [srcId, setSrcId] = useState<number | undefined>();
useEffect(() => {
setAmount(opening ? String(opening.amount) : "0");
setReason(opening?.reason ?? "");
}, [opening?.amount, opening?.reason]);
const { data: cands } = useQuery({
queryKey: ["opening-candidates", id], queryFn: () => api.openingCandidates(id),
});
const priors = cands?.candidates ?? [];
const chosen = srcId ?? priors[0]?.session_id;
const save = useMutation({
mutationFn: () => api.putOpenings(id, [{ marketplace: mkt, amount: Number(amount) || 0, reason, source: "manual" }]),
onSuccess: () => { setEditing(false); onSaved(); },
});
const carry = useMutation({
mutationFn: () => api.carryForwardOpenings(id, chosen),
onSuccess: () => { setPickSource(false); onSaved(); },
});
const reset = useMutation({ mutationFn: () => api.resetOpenings(id), onSuccess: onSaved });
return (
<div className="card p-4 flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="flex-1 min-w-[220px]">
<div className="text-xs font-semibold text-muted uppercase tracking-wide">
Opening AR balance · {mkt}
</div>
{!editing ? (
<div className="flex items-center gap-3 mt-1">
<span className="text-2xl font-semibold num text-ink">{money(opening?.amount ?? 0, cur)}</span>
<StatusBadge status={opening?.source === "carried_forward" ? "info" : "draft"} />
{opening?.reason && <span className="text-xs text-subink">{opening.reason}</span>}
</div>
) : (
<div className="flex flex-wrap items-center gap-2 mt-1.5">
<input className="input num w-48" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="0.00" />
<input className="input w-64" value={reason} onChange={(e) => setReason(e.target.value)}
placeholder="Reason for adjustment (required to change)" />
</div>
)}
<p className="text-xs text-subink mt-1">
Entered manually for the first month; from month 2 it carries forward automatically from the prior closing.
</p>
</div>
{!editing ? (
<div className="flex flex-wrap items-center gap-2">
<button className="btn-ghost" onClick={() => setEditing(true)}><Pencil size={15} /> Adjust</button>
{priors.length > 0 && (
<button className="btn-ghost" disabled={carry.isPending}
onClick={() => setPickSource((v) => !v)}>
<CornerDownRight size={15} /> {carry.isPending ? "Carrying…" : "Carry forward"}
</button>
)}
<button className="btn-ghost" disabled={reset.isPending} onClick={() => reset.mutate()}>
<RotateCcw size={15} /> Set to zero
</button>
</div>
) : (
<div className="flex gap-2">
<button className="btn-ghost" onClick={() => setEditing(false)}>Cancel</button>
<button className="btn-primary" disabled={save.isPending} onClick={() => save.mutate()}>
<Save size={15} /> {save.isPending ? "Saving…" : "Save"}
</button>
</div>
)}
{pickSource && priors.length > 0 && (
<div className="w-full border-t border-line pt-3 flex flex-wrap items-end gap-2">
<div className="flex-1 min-w-[220px]">
<label className="label mb-1">Carry forward from</label>
<select className="input py-1.5 text-sm" value={chosen ?? ""}
onChange={(e) => setSrcId(Number(e.target.value))}>
{priors.map((p) => (
<option key={p.session_id} value={p.session_id}>
{p.name}{p.reporting_month ? ` · ${p.reporting_month}` : ""}
{`${p.markets.length} marketplace${p.markets.length === 1 ? "" : "s"}`}
</option>
))}
</select>
</div>
<button className="btn-ghost" onClick={() => setPickSource(false)}>Cancel</button>
<button className="btn-primary" disabled={carry.isPending} onClick={() => carry.mutate()}>
Apply to all marketplaces
</button>
<p className="w-full text-xs text-subink">
Sets every marketplace's opening balance to that closing's closing receivable.
</p>
</div>
)}
{(carry.isError || reset.isError || save.isError) && (
<p className="w-full text-sm text-bad">
{((carry.error || reset.error || save.error) as Error)?.message}
</p>
)}
</div>
);
}