Part B: Cash Flow comparison period + Budget vs Actuals proration

- Cash Flow Statement now supports a comparison period (prior-period/prior-year/
  custom): refactored into computeCashFlow() and renders the comparison column
  for Net Income, CFO/CFI/CFF, beginning/ending cash. Previously it ignored the
  compare toggle. (P&L and Movement of Equity already had comparison.)
- Budget vs Actuals: budget is now pro-rated to the selected actuals window
  (sum each budget period weighted by overlap with the range) instead of always
  using the full annual figure — honors monthly/quarterly/annual period_type.
  Noted in the on-screen card and the PDF header (B4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 01:22:35 -04:00
parent 21224e400d
commit 5383404bb0
+61 -34
View File
@@ -1139,80 +1139,82 @@ function buildBalanceSheet(d: any): StructuredReport {
// Indirect-method cash flow built from the GL (§5). It ties to the change in the // Indirect-method cash flow built from the GL (§5). It ties to the change in the
// Balance Sheet cash accounts by construction (R4): because every entry balances, // Balance Sheet cash accounts by construction (R4): because every entry balances,
// the cash impact of net income + all non-cash balance movements equals ΔCash. // the cash impact of net income + all non-cash balance movements equals ΔCash.
function buildCashFlow(d: any, _p: any | undefined, _useCompare: boolean): StructuredReport { type CashFlowCalc = {
netIncome: number; operating: { label: string; amount: number }[];
cfo: number; cfi: number; cff: number; netChange: number;
beginCash: number; endCash: number; residual: number;
};
function computeCashFlow(d: any): CashFlowCalc {
const from: string = d.from; const from: string = d.from;
const acctById = new Map<string, any>((d.accounts ?? []).map((a: any) => [a.id, a])); const acctById = new Map<string, any>((d.accounts ?? []).map((a: any) => [a.id, a]));
const isCash = (a: any) => !!a && (a.is_bank || /cash|undeposited/i.test(String(a.name || ""))); const isCash = (a: any) => !!a && (a.is_bank || /cash|undeposited/i.test(String(a.name || "")));
// Beginning (date < from) and ending (<= asOf) raw balances (debit credit).
const beginRaw = new Map<string, number>(); const beginRaw = new Map<string, number>();
const endRaw = new Map<string, number>(); const endRaw = new Map<string, number>();
for (const l of (d.glCumulative ?? []) as any[]) { for (const l of (d.glCumulative ?? []) as any[]) {
const raw = Number(l.debit || 0) - Number(l.credit || 0); const raw = Number(l.debit || 0) - Number(l.credit || 0);
endRaw.set(l.account_id, (endRaw.get(l.account_id) ?? 0) + raw); endRaw.set(l.account_id, (endRaw.get(l.account_id) ?? 0) + raw);
if (String(l.journal_entries?.date ?? "") < from) { if (String(l.journal_entries?.date ?? "") < from) beginRaw.set(l.account_id, (beginRaw.get(l.account_id) ?? 0) + raw);
beginRaw.set(l.account_id, (beginRaw.get(l.account_id) ?? 0) + raw);
}
} }
const ids = new Set<string>([...endRaw.keys(), ...beginRaw.keys()]); const ids = new Set<string>([...endRaw.keys(), ...beginRaw.keys()]);
const deltaRaw = (id: string) => (endRaw.get(id) ?? 0) - (beginRaw.get(id) ?? 0); const deltaRaw = (id: string) => (endRaw.get(id) ?? 0) - (beginRaw.get(id) ?? 0);
let beginCash = 0, endCash = 0, revenue = 0, expense = 0; let beginCash = 0, endCash = 0, revenue = 0, expense = 0, cfi = 0, cff = 0;
const operating: { label: string; amount: number }[] = []; const operating: { label: string; amount: number }[] = [];
let cfi = 0, cff = 0;
for (const id of ids) { for (const id of ids) {
const a = acctById.get(id); const a = acctById.get(id);
if (!a) continue; if (!a) continue;
if (isCash(a)) { beginCash += beginRaw.get(id) ?? 0; endCash += endRaw.get(id) ?? 0; continue; } if (isCash(a)) { beginCash += beginRaw.get(id) ?? 0; endCash += endRaw.get(id) ?? 0; continue; }
if (a.type === "income") { revenue += -deltaRaw(id); continue; } // natural = raw if (a.type === "income") { revenue += -deltaRaw(id); continue; }
if (a.type === "expense") { expense += deltaRaw(id); continue; } // natural = raw if (a.type === "expense") { expense += deltaRaw(id); continue; }
// Non-cash balance-sheet account: cash impact of its movement = −Δraw.
const impact = -deltaRaw(id); const impact = -deltaRaw(id);
if (Math.abs(impact) < 0.005) continue; if (Math.abs(impact) < 0.005) continue;
const name = String(a.name || "").toLowerCase(); const name = String(a.name || "").toLowerCase();
if (a.type === "asset") { if (a.type === "asset") {
const naturalUp = deltaRaw(id) > 0; // asset natural = raw const naturalUp = deltaRaw(id) > 0;
if (/investment|property|equipment|fixed|capital asset/.test(name)) cfi += impact; if (/investment|property|equipment|fixed|capital asset/.test(name)) cfi += impact;
else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact }); else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact });
} else if (a.type === "liability") { } else if (a.type === "liability") {
const naturalUp = -deltaRaw(id) > 0; // liability natural = raw const naturalUp = -deltaRaw(id) > 0;
if (/loan|note|mortgage|debt|bond/.test(name)) cff += impact; if (/loan|note|mortgage|debt|bond/.test(name)) cff += impact;
else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact }); else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact });
} else if (a.type === "equity") { } else if (a.type === "equity") { cff += impact; }
cff += impact; // contributions / distributions / opening equity
} }
}
const netIncome = revenue - expense; const netIncome = revenue - expense;
const cfo = netIncome + operating.reduce((s, r) => s + r.amount, 0); const cfo = netIncome + operating.reduce((s, r) => s + r.amount, 0);
const netChange = cfo + cfi + cff; const netChange = cfo + cfi + cff;
const deltaCash = endCash - beginCash; return { netIncome, operating, cfo, cfi, cff, netChange, beginCash, endCash, residual: netChange - (endCash - beginCash) };
const residual = netChange - deltaCash; }
function buildCashFlow(d: any, p: any | undefined, useCompare: boolean): StructuredReport {
const cur = computeCashFlow(d);
const prev = useCompare && p ? computeCashFlow(p) : undefined;
const cmp = (v: number | undefined) => (useCompare && prev ? v : undefined);
// Match prior-period operating line items by label for the compare column.
const prevByLabel = new Map((prev?.operating ?? []).map((r) => [r.label, r.amount]));
const rows: StructuredRow[] = []; const rows: StructuredRow[] = [];
rows.push({ kind: "section", label: "Operating Activities" }); rows.push({ kind: "section", label: "Operating Activities" });
rows.push({ kind: "sub", label: "Net Income", amount: netIncome }); rows.push({ kind: "sub", label: "Net Income", amount: cur.netIncome, compare: cmp(prev?.netIncome) });
for (const r of operating) rows.push({ kind: "sub", label: r.label, amount: r.amount }); for (const r of cur.operating) rows.push({ kind: "sub", label: r.label, amount: r.amount, compare: cmp(prevByLabel.get(r.label) ?? 0) });
rows.push({ kind: "total", label: "Net Cash from Operating Activities", amount: cfo }); rows.push({ kind: "total", label: "Net Cash from Operating Activities", amount: cur.cfo, compare: cmp(prev?.cfo) });
rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "section", label: "Investing Activities" }); rows.push({ kind: "section", label: "Investing Activities" });
rows.push({ kind: "total", label: "Net Cash from Investing Activities", amount: cfi }); rows.push({ kind: "total", label: "Net Cash from Investing Activities", amount: cur.cfi, compare: cmp(prev?.cfi) });
rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "section", label: "Financing Activities" }); rows.push({ kind: "section", label: "Financing Activities" });
rows.push({ kind: "total", label: "Net Cash from Financing Activities", amount: cff }); rows.push({ kind: "total", label: "Net Cash from Financing Activities", amount: cur.cff, compare: cmp(prev?.cff) });
rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "sub", label: "Beginning Cash", amount: beginCash }); rows.push({ kind: "sub", label: "Beginning Cash", amount: cur.beginCash, compare: cmp(prev?.beginCash) });
rows.push({ kind: "sub", label: "Ending Cash", amount: endCash }); rows.push({ kind: "sub", label: "Ending Cash", amount: cur.endCash, compare: cmp(prev?.endCash) });
if (Math.abs(residual) >= 0.005) { if (Math.abs(cur.residual) >= 0.005) {
rows.push({ kind: "total", label: "⚠ Out of balance — R4 residual (CFO+CFI+CFF ΔCash)", amount: residual }); rows.push({ kind: "total", label: "⚠ Out of balance — R4 residual (CFO+CFI+CFF ΔCash)", amount: cur.residual });
} }
return { return {
title: "Cash Flow Statement", title: "Cash Flow Statement",
rows, rows,
cashHighlight: { label: "Net Change in Cash", amount: netChange }, cashHighlight: { label: "Net Change in Cash", amount: cur.netChange },
}; };
} }
@@ -1369,11 +1371,33 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
expense: orderAccountsHierarchically(grouped.expense ?? []), expense: orderAccountsHierarchically(grouped.expense ?? []),
} as Record<string, any[]>), [grouped]); } as Record<string, any[]>), [grouped]);
const selectedBudget = useMemo(() => (budgets as any[]).find((b) => b.id === budgetId), [budgets, budgetId]);
// Budget pro-rated to the selected actuals window (B2): sum each budget period
// weighted by how much of it overlaps [actFrom, actTo]. period_index maps to
// months (monthly), quarters (quarterly), or the whole year (annual).
const budgetByAcct = useMemo(() => { const budgetByAcct = useMemo(() => {
const m: Record<string, number> = {}; const m: Record<string, number> = {};
for (const e of (entries as any[])) m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount); const pt = String(selectedBudget?.period_type ?? "annual");
const fy = Number(selectedBudget?.fiscal_year) || new Date(actFrom || actTo || Date.now()).getFullYear();
const fromT = actFrom ? new Date(actFrom).getTime() : -Infinity;
const toT = actTo ? new Date(actTo).getTime() : Infinity;
const DAY = 86400000;
const span = (idx: number): [number, number] => {
if (pt === "monthly") return [new Date(fy, idx, 1).getTime(), new Date(fy, idx + 1, 0).getTime()];
if (pt === "quarterly") return [new Date(fy, idx * 3, 1).getTime(), new Date(fy, idx * 3 + 3, 0).getTime()];
return [new Date(fy, 0, 1).getTime(), new Date(fy, 11, 31).getTime()];
};
for (const e of (entries as any[])) {
const [s, en] = span(Number(e.period_index) || 0);
const overlap = Math.max(0, Math.min(en, toT) - Math.max(s, fromT));
const full = en - s;
const weight = full > 0 ? Math.min(1, (overlap + DAY) / (full + DAY)) : 1;
if (weight <= 0) continue;
m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount) * weight;
}
return m; return m;
}, [entries]); }, [entries, selectedBudget, actFrom, actTo]);
const actualByAcct = useMemo(() => { const actualByAcct = useMemo(() => {
const m: Record<string, number> = {}; const m: Record<string, number> = {};
@@ -1477,7 +1501,7 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41); doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41);
doc.text("Budget vs Actuals", 40, 50); doc.text("Budget vs Actuals", 40, 50);
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(110, 116, 122); doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(110, 116, 122);
doc.text(`${companyName} · ${actualsLabel}`, 40, 66); doc.text(`${companyName} · ${actualsLabel} · Budget pro-rated to period`, 40, 66);
autoTable(doc, { autoTable(doc, {
startY: 80, startY: 80,
head: [["Account", "Budget", "Actual", "Variance", "Variance %"]], head: [["Account", "Budget", "Actual", "Variance", "Variance %"]],
@@ -1519,6 +1543,9 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
<Button variant="outline" onClick={exportCSV}><Download className="mr-1 h-4 w-4" /> CSV</Button> <Button variant="outline" onClick={exportCSV}><Download className="mr-1 h-4 w-4" /> CSV</Button>
<Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> PDF</Button> <Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
</div> </div>
<div className="w-full text-xs text-muted-foreground">
Budget pro-rated to the selected period ({String((selectedBudget as any)?.period_type ?? "annual")} budget).
</div>
</CardContent> </CardContent>
</Card> </Card>