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("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 ; if (!mv || !mv.available) return ; 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 ? (
{money(v, "USD", dp)}
) : 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 (
{multi && (
{showAll ? "Every marketplace with the combined total." : <>Opening balance, revenue and payouts below are for {mkt} only.}
)} {showAll ? ( ) : ( <> { qc.invalidateQueries({ queryKey: ["openings", id] }); qc.invalidateQueries({ queryKey: ["ar-movement", id] }); qc.invalidateQueries({ queryKey: ["ledger-detail", id] }); }} />
}>
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 {m(mv.closing)}
{dual && detail?.month_rate != null && (
in USD @ month rate {num(detail.month_rate, 6)} {money((mv.closing ?? 0) * detail.month_rate, "USD", 2)}
)}

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

{(mv.ledger ?? []).map((r, i) => { const key = r.period === "Opening" || r.period === "Closing"; return ( ); })}
PeriodDescription DebitCredit Running AR
{r.period} {r.debit != null && } {r.credit != null && } {r.description} {r.debit != null ? acct(r.debit) : ""} {r.credit != null ? acct(r.credit) : ""} {m(r.balance)}
{/* Bank receipts: when each payout actually reached the bank (drives received vs in-transit and the Movement-by-date placement below). */}
Cross-check vs settlement method: roll-forward closing {m(mv.closing)} vs settlement-based{" "} {m(mv.settlement_closing ?? undefined)} —{" "} {reconciled ? "match" : `difference ${m(diff, 2)}`}.
{!reconciled && (

A difference usually means the opening balance or a reserve/timing item needs review.

)}
{/* ---------------- date-filtered movement ---------------- */}
{(["day", "week", "month"] as Gran[]).map((g) => ( ))}
setFrom(e.target.value)} />
setTo(e.target.value)} />
{(from || to) && ( )}
{detail?.filtered ? "Custom range — balance covers the filtered dates only" : "Full month"}
{(detail?.periods ?? []).map((p) => ( ))}
Period Rows Net revenue Payouts received In transit Running AR
Opening {m(detail?.opening)} {inUsd(detail?.opening_usd)}
{p.label} {p.rows.toLocaleString()} {acct(p.revenue)} {inUsd(p.revenue_usd)} {p.payouts_received ? acct(p.payouts_received) : ""} {p.payouts_received ? inUsd(p.payouts_received_usd) : null} {p.payouts_in_transit ? acct(p.payouts_in_transit) : ""} {p.payouts_in_transit ? inUsd(p.payouts_in_transit_usd) : null} {m(p.balance)} {inUsd(p.balance_usd)}
Closing {m(detail?.closing)} {inUsd(detail?.closing_usd)}
{detail && !detail.filtered && (

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.

)}
{/* ---------------- daily FX ---------------- */}
: undefined}> {cur === "USD" && (
{mkt} reports in USD — no conversion applied (rate 1.000000).
)}
{(fx?.rows ?? []).map((r) => ( ))}
Date Local ({fx?.currency ?? cur}) USD rate USD value Rate source
{r.date} {money(r.local, fx?.currency ?? cur, 2)} {num(r.rate, 6)} {money(r.usd, "USD", 2)} {r.source}
Total {money(fx?.total_local, fx?.currency ?? cur, 2)} {money(fx?.total_usd, "USD", 2)}

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

Gross revenue {m(mv.gross_revenue, 2)} → net revenue {m(mv.net_revenue, 2)} after refunds and Amazon fees. See the Journal Entry tab for the full line-by-line breakdown.

)}
); } /** 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 (
{fetchDaily.isSuccess && ( {fetchDaily.data.saved} daily rate(s) loaded ({fetchDaily.data.provider}). )} {fetchDaily.isError && ( {(fetchDaily.error as Error).message} )}
); } function Row({ label, v, cur, bold, pos }: { label: ReactNode; v: number; cur: string; bold?: boolean; pos?: boolean; }) { return (
{label} {money(v, cur, 2)}
); } 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(); 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 (
Opening AR balance · {mkt}
{!editing ? (
{money(opening?.amount ?? 0, cur)} {opening?.reason && {opening.reason}}
) : (
setAmount(e.target.value)} placeholder="0.00" /> setReason(e.target.value)} placeholder="Reason for adjustment (required to change)" />
)}

Entered manually for the first month; from month 2 it carries forward automatically from the prior closing.

{!editing ? (
{priors.length > 0 && ( )}
) : (
)} {pickSource && priors.length > 0 && (

Sets every marketplace's opening balance to that closing's closing receivable.

)} {(carry.isError || reset.isError || save.isError) && (

{((carry.error || reset.error || save.error) as Error)?.message}

)}
); }