diff --git a/src/app/(dashboard)/[portSlug]/reports/financial/page.tsx b/src/app/(dashboard)/[portSlug]/reports/financial/page.tsx new file mode 100644 index 00000000..872f0275 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/reports/financial/page.tsx @@ -0,0 +1,21 @@ +import { FinancialReportClient } from '@/components/reports/financial/financial-report-client'; + +export const dynamic = 'force-dynamic'; + +/** + * Financial report. + * + * Sibling of the dynamic [kind] route so this page wins over the + * placeholder for /reports/financial specifically. Spec lives in + * docs/reports-content-spec.md § Report 02 — sourced from the canonical + * payments + expenses tables (the CRM records money received; it does + * not invoice). + */ +export default async function FinancialReportPage({ + params, +}: { + params: Promise<{ portSlug: string }>; +}) { + const { portSlug } = await params; + return ; +} diff --git a/src/app/(dashboard)/[portSlug]/reports/page.tsx b/src/app/(dashboard)/[portSlug]/reports/page.tsx index 52ab3cd0..c1301ac3 100644 --- a/src/app/(dashboard)/[portSlug]/reports/page.tsx +++ b/src/app/(dashboard)/[portSlug]/reports/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; import type { Route } from 'next'; -import { BookOpen, Calendar, Clock, Layers, Sparkles, TrendingUp } from 'lucide-react'; +import { BookOpen, Calendar, Clock, Layers, Sparkles, TrendingUp, Wallet } from 'lucide-react'; import { PageHeader } from '@/components/shared/page-header'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -39,6 +39,13 @@ const KIND_CARDS: KindCard[] = [ 'Berth utilisation timeline, occupancy heatmap, tenancy churn, signing turnaround.', icon: Layers, }, + { + href: 'financial', + label: 'Financial', + description: + 'Revenue collected, deposits, outstanding balances, cash flow, and expense breakdown.', + icon: Wallet, + }, { href: 'custom', label: 'Custom report', diff --git a/src/app/api/v1/reports/financial/route.ts b/src/app/api/v1/reports/financial/route.ts new file mode 100644 index 00000000..997d32c1 --- /dev/null +++ b/src/app/api/v1/reports/financial/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { + getFinancialKpis, + getRevenueByMonth, + getCollectionFunnel, + getExpectedDepositAging, + getCashFlow, + getExpenseBreakdown, + getOutstandingDeposits, + getRecentPayments, + getRefundLog, + getExpenseLedger, +} from '@/lib/services/reports/financial.service'; + +/** + * GET /api/v1/reports/financial?from=&to= + * + * Financial report payload, sourced from the canonical `payments` + + * `expenses` tables (the CRM records money received; it does not invoice + * — see docs/reports-content-spec.md § Report 02). "Outstanding AR" is + * reframed as expected-deposit shortfall on active deals. + * + * Permission: `reports.view_dashboard` (same gate as the other report + * dashboards). + */ +const querySchema = z.object({ + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), +}); + +function resolveRange(from?: string, to?: string): { from: Date; to: Date } { + const now = new Date(); + // Default: trailing 12 months — financial trends read better over a + // year than 30 days, and the month/cash-flow charts want the span. + const defaultFrom = new Date(now); + defaultFrom.setMonth(defaultFrom.getMonth() - 12); + return { + from: from ? new Date(from) : defaultFrom, + to: to ? new Date(to) : now, + }; +} + +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => { + try { + const params = req.nextUrl.searchParams; + const { from, to } = querySchema.parse({ + from: params.get('from') ?? undefined, + to: params.get('to') ?? undefined, + }); + const range = resolveRange(from, to); + + const [ + kpis, + revenueByMonth, + collectionFunnel, + aging, + cashFlow, + expenseBreakdown, + outstandingDeposits, + recentPayments, + refundLog, + expenseLedger, + ] = await Promise.all([ + getFinancialKpis(ctx.portId, range), + getRevenueByMonth(ctx.portId, range), + getCollectionFunnel(ctx.portId, range), + getExpectedDepositAging(ctx.portId), + getCashFlow(ctx.portId, range), + getExpenseBreakdown(ctx.portId, range), + getOutstandingDeposits(ctx.portId), + getRecentPayments(ctx.portId, range), + getRefundLog(ctx.portId, range), + getExpenseLedger(ctx.portId, range), + ]); + + return NextResponse.json({ + data: { + kpis, + revenueByMonth, + collectionFunnel, + aging, + cashFlow, + expenseBreakdown, + outstandingDeposits, + recentPayments, + refundLog, + expenseLedger, + range: { from: range.from.toISOString(), to: range.to.toISOString() }, + }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/reports/templates/route.ts b/src/app/api/v1/reports/templates/route.ts index ff06e014..7bff2515 100644 --- a/src/app/api/v1/reports/templates/route.ts +++ b/src/app/api/v1/reports/templates/route.ts @@ -12,7 +12,17 @@ const createBodySchema = z.object({ // for these kinds is a thin view-state snapshot (date range + // filters) that the report client applies on load. 'custom' is the // ad-hoc composer's saved config — entity + columns + filter. - kind: z.enum(['dashboard', 'clients', 'berths', 'interests', 'sales', 'operational', 'custom']), + kind: z.enum([ + 'dashboard', + 'clients', + 'berths', + 'interests', + 'sales', + 'operational', + 'financial', + 'marketing', + 'custom', + ]), name: z.string().min(1).max(120), description: z.string().max(400).nullable().optional(), // Config is the raw discriminated-union payload; the diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index fa01119f..68877f0b 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -15,11 +15,12 @@ import { WidgetErrorBoundary } from './widget-error-boundary'; import type { DashboardWidget } from './widget-registry'; import { isCustomRange, type DateRange } from '@/lib/analytics/range'; -const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = { +const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d' | '1y', string> = { today: 'Today', '7d': 'Last 7 days', '30d': 'Last 30 days', '90d': 'Last 90 days', + '1y': 'Last 12 months', }; function rangeLabel(range: DateRange): string { diff --git a/src/components/dashboard/date-range-picker.tsx b/src/components/dashboard/date-range-picker.tsx index ab07ab38..d8fc3797 100644 --- a/src/components/dashboard/date-range-picker.tsx +++ b/src/components/dashboard/date-range-picker.tsx @@ -14,11 +14,12 @@ interface DateRangePickerProps { className?: string; } -const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d'; label: string }> = [ +const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d' | '1y'; label: string }> = [ { value: 'today', label: 'Today' }, { value: '7d', label: '7d' }, { value: '30d', label: '30d' }, { value: '90d', label: '90d' }, + { value: '1y', label: '1y' }, ]; /** diff --git a/src/components/dashboard/website-glance-tile.tsx b/src/components/dashboard/website-glance-tile.tsx index e9c8dcc4..6738662b 100644 --- a/src/components/dashboard/website-glance-tile.tsx +++ b/src/components/dashboard/website-glance-tile.tsx @@ -28,11 +28,12 @@ interface Props { range?: DateRange; } -const RANGE_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = { +const RANGE_LABELS: Record<'today' | '7d' | '30d' | '90d' | '1y', string> = { today: 'Today', '7d': '7 days', '30d': '30 days', '90d': '90 days', + '1y': '12 months', }; function shortRangeLabel(range: DateRange): string { diff --git a/src/components/reports/financial/financial-report-client.tsx b/src/components/reports/financial/financial-report-client.tsx new file mode 100644 index 00000000..a5107182 --- /dev/null +++ b/src/components/reports/financial/financial-report-client.tsx @@ -0,0 +1,850 @@ +'use client'; + +import { useMemo, useState, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { PageHeader } from '@/components/shared/page-header'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { DateRangePicker } from '@/components/dashboard/date-range-picker'; +import { ReportExportButton } from '@/components/reports/shared/report-export-button'; +import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button'; +import { rangeToBounds, type DateRange } from '@/lib/analytics/range'; +import { apiFetch } from '@/lib/api/client'; +import { formatMoney, formatMoneyCompact, formatNumber } from '@/lib/reports/format-currency'; +import type { ReportPayload } from '@/lib/reports/types'; + +// ─── Payload types (mirror the /api/v1/reports/financial response) ─────────── + +interface FinancialKpis { + revenueCollected: number; + depositsCollected: number; + balanceCollected: number; + refundsIssued: number; + pipelineExpected: number; + expectedDepositsOutstanding: number; + expensesTotal: number; + netContribution: number; + currency: string; +} +interface RevenueByMonthRow { + month: string; + deposit: number; + balance: number; +} +interface CollectionFunnelRow { + stage: string; + label: string; + count: number; +} +interface AgingRow { + bucket: string; + count: number; + value: number; +} +interface CashFlowRow { + month: string; + inflow: number; + outflow: number; +} +interface ExpenseBreakdownRow { + category: string; + total: number; +} +interface OutstandingDepositRow { + interestId: string; + clientName: string; + mooring: string | null; + expected: number; + collected: number; + remaining: number; + currency: string; + daysOutstanding: number; +} +interface RecentPaymentRow { + id: string; + receivedAt: string; + clientName: string; + mooring: string | null; + paymentType: string; + amount: number; + currency: string; +} +interface RefundRow { + id: string; + receivedAt: string; + clientName: string; + amount: number; + currency: string; + notes: string | null; +} +interface ExpenseLedgerRow { + id: string; + expenseDate: string; + payer: string | null; + category: string | null; + establishmentName: string | null; + amount: number; + currency: string; + paymentStatus: string | null; +} + +interface FinancialPayload { + data: { + kpis: FinancialKpis; + revenueByMonth: RevenueByMonthRow[]; + collectionFunnel: CollectionFunnelRow[]; + aging: AgingRow[]; + cashFlow: CashFlowRow[]; + expenseBreakdown: ExpenseBreakdownRow[]; + outstandingDeposits: OutstandingDepositRow[]; + recentPayments: RecentPaymentRow[]; + refundLog: RefundRow[]; + expenseLedger: ExpenseLedgerRow[]; + range: { from: string; to: string }; + }; +} + +interface FinancialTemplateConfig extends Record { + kind: 'financial'; + range: DateRange; +} + +type MonthGranularity = 'month' | 'quarter' | 'year'; + +const DONUT_COLORS = [ + 'hsl(var(--chart-1))', + 'hsl(var(--chart-3))', + 'hsl(var(--chart-4))', + 'hsl(var(--chart-5))', + 'hsl(var(--chart-2))', + 'hsl(var(--chart-6))', +]; + +export function FinancialReportClient({ portSlug: _portSlug }: { portSlug: string }) { + const searchParams = useSearchParams(); + const initialTemplateId = searchParams?.get('templateId') ?? null; + + const [range, setRange] = useState('1y'); + const [granularity, setGranularity] = useState('month'); + const [activeTemplateId, setActiveTemplateId] = useState(initialTemplateId); + + const handleRangeChange = useCallback((next: DateRange) => { + setRange(next); + setActiveTemplateId(null); + }, []); + + const currentConfig: FinancialTemplateConfig = useMemo( + () => ({ kind: 'financial', range }), + [range], + ); + + const handleApplyTemplate = useCallback((config: FinancialTemplateConfig) => { + if (config.range) setRange(config.range); + }, []); + + const bounds = useMemo(() => rangeToBounds(range), [range]); + + const query = useQuery({ + queryKey: ['reports', 'financial', bounds.from.toISOString(), bounds.to.toISOString()], + queryFn: () => + apiFetch( + `/api/v1/reports/financial?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}`, + ), + staleTime: 30_000, + }); + + const d = query.data?.data; + const kpis = d?.kpis; + const currency = kpis?.currency ?? 'EUR'; + const revenueByMonth = d?.revenueByMonth ?? []; + const collectionFunnel = d?.collectionFunnel ?? []; + const aging = d?.aging ?? []; + const expenseBreakdown = d?.expenseBreakdown ?? []; + const outstandingDeposits = d?.outstandingDeposits ?? []; + const recentPayments = d?.recentPayments ?? []; + const refundLog = d?.refundLog ?? []; + const expenseLedger = d?.expenseLedger ?? []; + + // Re-bucket the monthly revenue series for the quarter/year toggle. + // Depend on the query-data reference (stable across renders once + // loaded) rather than the `?? []` fallback, which would be a fresh + // array each render. + const revenueSeries = useMemo( + () => rebucketRevenue(d?.revenueByMonth ?? [], granularity), + [d?.revenueByMonth, granularity], + ); + const cashFlowSeries = useMemo( + () => (d?.cashFlow ?? []).map((r) => ({ ...r, label: formatMonthLabel(r.month) })), + [d?.cashFlow], + ); + const fundedCount = collectionFunnel.length > 0 ? collectionFunnel[0]!.count : 0; + + function buildExportPayload(): ReportPayload { + if (!kpis) throw new Error('Report still loading'); + return { + title: 'Financial', + description: 'Revenue collected, deposits, outstanding, cash flow, and expenses.', + filenameSlug: 'financial', + range: bounds, + kpis: [ + { label: 'Revenue collected', value: formatMoney(kpis.revenueCollected, currency) }, + { label: 'Deposits collected', value: formatMoney(kpis.depositsCollected, currency) }, + { label: 'Balance collected', value: formatMoney(kpis.balanceCollected, currency) }, + { + label: 'Pipeline (expected deposits)', + value: formatMoney(kpis.pipelineExpected, currency), + }, + { + label: 'Outstanding deposits', + value: formatMoney(kpis.expectedDepositsOutstanding, currency), + }, + { label: 'Expenses', value: formatMoney(kpis.expensesTotal, currency) }, + { label: 'Net contribution', value: formatMoney(kpis.netContribution, currency) }, + ], + sections: [ + { + title: 'Revenue by month', + columns: [ + { key: 'month', label: 'Month' }, + { key: 'deposit', label: 'Deposits', align: 'right' }, + { key: 'balance', label: 'Balance', align: 'right' }, + ], + rows: revenueByMonth.map((r) => ({ + month: r.month, + deposit: formatMoney(r.deposit, currency), + balance: formatMoney(r.balance, currency), + })), + }, + { + title: 'Outstanding deposits', + columns: [ + { key: 'clientName', label: 'Client' }, + { key: 'mooring', label: 'Berth' }, + { key: 'remaining', label: 'Remaining', align: 'right' }, + { key: 'daysOutstanding', label: 'Age (days)', align: 'right' }, + ], + rows: outstandingDeposits.map((r) => ({ + clientName: r.clientName, + mooring: r.mooring ?? '—', + remaining: formatMoney(r.remaining, r.currency), + daysOutstanding: r.daysOutstanding, + })), + }, + { + title: 'Expense ledger', + columns: [ + { key: 'expenseDate', label: 'Date' }, + { key: 'category', label: 'Category' }, + { key: 'payer', label: 'Payer' }, + { key: 'amount', label: 'Amount', align: 'right' }, + { key: 'paymentStatus', label: 'Status' }, + ], + rows: expenseLedger.map((r) => ({ + expenseDate: r.expenseDate ? r.expenseDate.slice(0, 10) : '—', + category: r.category ?? '—', + payer: r.payer ?? '—', + amount: formatMoney(r.amount, r.currency), + paymentStatus: r.paymentStatus ?? '—', + })), + }, + ], + }; + } + + const isLoading = query.isLoading || !kpis; + + return ( +
+ + + + kind="financial" + currentConfig={currentConfig} + onApply={handleApplyTemplate} + activeTemplateId={activeTemplateId} + onActiveTemplateChange={setActiveTemplateId} + initialTemplateId={initialTemplateId} + /> + +
+ } + /> + + {/* KPI STRIP — 7 tiles */} +
+ {isLoading ? ( + Array.from({ length: 7 }).map((_, i) => ) + ) : ( + <> + + + + + + 0 + ? `${formatMoney(kpis.refundsIssued, currency)} refunded` + : undefined + } + /> + = 0 ? 'positive' : 'negative'} + hint="Revenue − expenses" + /> + + )} +
+ + {/* CHART 1 — Revenue by month (stacked: deposit / balance) */} + + +
+ Revenue collected over time +

+ Payments received per period, split deposits vs balance. +

+
+
+ {(['month', 'quarter', 'year'] as MonthGranularity[]).map((g) => ( + + ))} +
+
+ + {isLoading ? ( + + ) : revenueSeries.every((r) => r.deposit === 0 && r.balance === 0) ? ( + + No payments recorded in this period. Revenue appears here as deposits and balances are + received on the Payments tab. + + ) : ( + + + + + formatMoneyCompact(Number(v), currency)} + tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} + width={64} + /> + [ + formatMoney(Number(value), currency), + name === 'deposit' ? 'Deposits' : 'Balance', + ]} + /> + + + + + )} + +
+ + {/* CHART 2 + 3 — Collection funnel + AR aging side by side */} +
+ + + Collection funnel +

+ Deals reaching each money milestone in the period. Highlights where revenue leaks. +

+
+ + {isLoading ? ( + + ) : ( +
+ {collectionFunnel.map((row) => { + const pct = fundedCount > 0 ? (row.count / fundedCount) * 100 : 0; + return ( +
+
+ {row.label} + + {formatNumber(row.count)} + +
+
+
0 ? 8 : 0)}%` }} + /> +
+
+ ); + })} +
+ )} + + + + + + Outstanding deposits by age +

+ Expected deposits not yet collected, bucketed by how long the deal has been open. +

+
+ + {isLoading ? ( + + ) : aging.every((r) => r.value === 0) ? ( + No outstanding deposits. Every open deal is paid up. + ) : ( + + + + formatMoneyCompact(Number(v), currency)} + tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} + /> + + [ + `${formatMoney(Number(value), currency)} · ${item?.payload?.count ?? 0} deal(s)`, + 'Outstanding', + ]} + /> + + + + )} + +
+
+ + {/* CHART 4 — Cash flow (inflow vs outflow) */} + + + Cash flow +

+ Money in (payments received) vs money out (expenses booked), per month. +

+
+ + {isLoading ? ( + + ) : ( + + + + + formatMoneyCompact(Number(v), currency)} + tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} + width={64} + /> + [ + formatMoney(Number(value), currency), + name === 'inflow' ? 'Inflow' : 'Outflow', + ]} + /> + + + + + )} + +
+ + {/* CHART 5 — Expense breakdown donut + Recent payments table */} +
+ + + Expense breakdown +

By category, for the selected period.

+
+ + {isLoading ? ( + + ) : expenseBreakdown.length === 0 ? ( + No expenses booked in this period. + ) : ( + + + + {expenseBreakdown.map((_, i) => ( + + ))} + + [ + formatMoney(Number(value), currency), + String(name), + ]} + /> + + + )} + +
+ + + + Recent payments +

Latest money received.

+
+ + {isLoading ? ( + + ) : recentPayments.length === 0 ? ( + No payments recorded in this period. + ) : ( + [ + p.receivedAt ? p.receivedAt.slice(0, 10) : '—', + p.clientName, + + {p.paymentType} + , + + {formatMoney(p.amount, p.currency)} + , + ])} + /> + )} + +
+
+ + {/* TABLES — Outstanding deposits + Expense ledger + Refunds */} + + + Outstanding deposits +

+ Open deals with an expected deposit not yet fully collected. The chase list. +

+
+ + {isLoading ? ( + + ) : outstandingDeposits.length === 0 ? ( + Nothing outstanding. Every open deal is paid up. + ) : ( + [ + r.clientName, + r.mooring ?? '—', + + {formatMoney(r.expected, r.currency)} + , + + {formatMoney(r.collected, r.currency)} + , + + {formatMoney(r.remaining, r.currency)} + , + `${r.daysOutstanding}d`, + ])} + /> + )} + +
+ +
+ + + Expense ledger +

Expenses booked in the period.

+
+ + {isLoading ? ( + + ) : expenseLedger.length === 0 ? ( + No expenses booked in this period. + ) : ( + [ + r.expenseDate ? r.expenseDate.slice(0, 10) : '—', + r.category ?? '—', + r.payer ?? '—', + + {formatMoney(r.amount, r.currency)} + , + + {r.paymentStatus ?? '—'} + , + ])} + /> + )} + +
+ + + + Refunds & write-offs +

+ Refund-type payments issued in the period. +

+
+ + {isLoading ? ( + + ) : refundLog.length === 0 ? ( + No refunds in this period. + ) : ( + [ + r.receivedAt ? r.receivedAt.slice(0, 10) : '—', + r.clientName, + + {formatMoney(r.amount, r.currency)} + , + r.notes ?? '—', + ])} + /> + )} + +
+
+
+ ); +} + +// ─── helpers + primitives ──────────────────────────────────────────────────── + +const tooltipStyle = { + background: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + fontSize: 12, +} as const; + +function formatMonthLabel(month: string): string { + const [year, m] = month.split('-'); + if (!year || !m) return month; + const date = new Date(parseInt(year), parseInt(m) - 1, 1); + return date.toLocaleDateString(undefined, { month: 'short', year: '2-digit' }); +} + +/** Re-bucket the monthly revenue series to month / quarter / year for the toggle. */ +function rebucketRevenue( + rows: RevenueByMonthRow[], + granularity: MonthGranularity, +): Array<{ label: string; deposit: number; balance: number }> { + if (granularity === 'month') { + return rows.map((r) => ({ + label: formatMonthLabel(r.month), + deposit: r.deposit, + balance: r.balance, + })); + } + const byKey = new Map(); + for (const r of rows) { + const [year, m] = r.month.split('-'); + if (!year || !m) continue; + const key = granularity === 'year' ? year : `${year}-Q${Math.floor((parseInt(m) - 1) / 3) + 1}`; + const acc = byKey.get(key) ?? { deposit: 0, balance: 0 }; + acc.deposit += r.deposit; + acc.balance += r.balance; + byKey.set(key, acc); + } + return Array.from(byKey.entries()).map(([label, v]) => ({ label, ...v })); +} + +interface KpiCardProps { + label: string; + value: string; + hint?: string; + valueTone?: 'positive' | 'negative' | 'neutral'; +} + +function KpiCard({ label, value, hint, valueTone = 'neutral' }: KpiCardProps) { + return ( + +

+ {label} +

+

+ {value} +

+ {hint ? ( +

{hint}

+ ) : null} +
+ ); +} + +function KpiSkeleton() { + return ( + + + + + + ); +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( +
+ + No data + +

{children}

+
+ ); +} + +function SimpleTable({ head, rows }: { head: string[]; rows: React.ReactNode[][] }) { + return ( +
+ + + + {head.map((h) => ( + + ))} + + + + {rows.map((cells, i) => ( + + {cells.map((c, j) => ( + + ))} + + ))} + +
+ {h} +
+ {c} +
+
+ ); +} diff --git a/src/components/reports/shared/report-templates-button.tsx b/src/components/reports/shared/report-templates-button.tsx index 20f69823..85d7d24a 100644 --- a/src/components/reports/shared/report-templates-button.tsx +++ b/src/components/reports/shared/report-templates-button.tsx @@ -23,7 +23,7 @@ import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import type { ReportTemplate } from '@/lib/db/schema/reports'; -type StandaloneReportKind = 'sales' | 'operational' | 'custom'; +type StandaloneReportKind = 'sales' | 'operational' | 'financial' | 'marketing' | 'custom'; interface ListResponse { data: ReportTemplate[]; diff --git a/src/lib/analytics/range.ts b/src/lib/analytics/range.ts index 3b2fe575..9de10303 100644 --- a/src/lib/analytics/range.ts +++ b/src/lib/analytics/range.ts @@ -12,7 +12,7 @@ /** * Preset date ranges used by the dashboard's quick-pick tabs. */ -export type PresetDateRange = '7d' | '30d' | '90d' | 'today'; +export type PresetDateRange = '7d' | '30d' | '90d' | '1y' | 'today'; /** * A custom date range expressed as a pair of ISO date strings (YYYY-MM-DD). @@ -27,7 +27,7 @@ export interface CustomDateRange { export type DateRange = PresetDateRange | CustomDateRange; -export const ALL_RANGES: readonly PresetDateRange[] = ['today', '7d', '30d', '90d'] as const; +export const ALL_RANGES: readonly PresetDateRange[] = ['today', '7d', '30d', '90d', '1y'] as const; export function isCustomRange(range: DateRange): range is CustomDateRange { return typeof range === 'object' && range.kind === 'custom'; @@ -87,6 +87,8 @@ export function rangeToDays(range: PresetDateRange): number { return 30; case '90d': return 90; + case '1y': + return 365; } } diff --git a/src/lib/services/reports/currency.ts b/src/lib/services/reports/currency.ts new file mode 100644 index 00000000..19f85edb --- /dev/null +++ b/src/lib/services/reports/currency.ts @@ -0,0 +1,32 @@ +import { eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; +import { convert } from '@/lib/services/currency'; + +/** + * Port's default currency for money normalisation. Falls back to USD + * when the column is null (legacy ports). Shared across the report + * services so every money figure lands in one reporting currency. + */ +export async function resolvePortCurrency(portId: string): Promise { + const [row] = await db + .select({ defaultCurrency: ports.defaultCurrency }) + .from(ports) + .where(eq(ports.id, portId)); + return row?.defaultCurrency ?? 'USD'; +} + +/** + * Convert `amount` from `from` → `to`. Returns the amount unchanged when + * the currencies match or a rate is unavailable (so a missing FX rate + * degrades to "report in source units" rather than dropping the figure). + */ +export async function normalizeAmount(amount: number, from: string, to: string): Promise { + if (!amount) return amount; + const f = from.toUpperCase(); + const t = to.toUpperCase(); + if (f === t) return amount; + const converted = await convert(amount, f, t); + return converted?.result ?? amount; +} diff --git a/src/lib/services/reports/financial-math.ts b/src/lib/services/reports/financial-math.ts new file mode 100644 index 00000000..24016981 --- /dev/null +++ b/src/lib/services/reports/financial-math.ts @@ -0,0 +1,51 @@ +/** + * Pure helpers for the Financial report. No DB / no currency I/O — kept + * separate from `financial.service.ts` so the bucketing + date maths are + * unit-testable in isolation. + */ + +export type AgingBucket = '0-30' | '31-60' | '61-90' | '90+'; + +/** Ascending so chart rows render oldest-band-last in a stable order. */ +export const AGING_BUCKETS: AgingBucket[] = ['0-30', '31-60', '61-90', '90+']; + +/** + * Map a day count to one of the four age bands. Used for the + * "expected deposits outstanding by deal age" chart — the payments-model + * analogue of invoice AR aging (we have no invoice due dates, so age is + * measured from when the deal started expecting a deposit). + */ +export function agingBucket(daysOutstanding: number): AgingBucket { + if (daysOutstanding <= 30) return '0-30'; + if (daysOutstanding <= 60) return '31-60'; + if (daysOutstanding <= 90) return '61-90'; + return '90+'; +} + +/** Zero-padded `YYYY-MM` key in UTC, for grouping money rows by month. */ +export function monthKey(d: Date): string { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + return `${y}-${m}`; +} + +/** + * Continuous ascending list of `YYYY-MM` keys spanning `from`..`to` + * inclusive. Gives the month/cash-flow charts a gap-free x-axis even for + * months with no rows (which then render as zero, not a missing bar). + */ +export function monthRange(from: Date, to: Date): string[] { + const keys: string[] = []; + const cursor = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), 1)); + const end = new Date(Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), 1)); + while (cursor <= end) { + keys.push(monthKey(cursor)); + cursor.setUTCMonth(cursor.getUTCMonth() + 1); + } + return keys; +} + +/** Net contribution = revenue collected − expenses. */ +export function netContribution(revenue: number, expenses: number): number { + return revenue - expenses; +} diff --git a/src/lib/services/reports/financial.service.ts b/src/lib/services/reports/financial.service.ts new file mode 100644 index 00000000..1e9199fa --- /dev/null +++ b/src/lib/services/reports/financial.service.ts @@ -0,0 +1,632 @@ +import { and, desc, eq, gte, isNull, lte, sql } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { clients } from '@/lib/db/schema/clients'; +import { interests, interestBerths } from '@/lib/db/schema/interests'; +import { berths } from '@/lib/db/schema/berths'; +import { payments } from '@/lib/db/schema/pipeline'; +import { expenses } from '@/lib/db/schema/financial'; +import { activeInterestsWhere } from '@/lib/services/active-interest'; +import { resolvePortCurrency, normalizeAmount } from './currency'; +import { + agingBucket, + AGING_BUCKETS, + monthKey, + monthRange, + netContribution, + type AgingBucket, +} from './financial-math'; + +import type { DateRange } from './sales.service'; + +// ─── KPI strip ─────────────────────────────────────────────────────────────── + +export interface FinancialKpis { + /** Σ of every payment received in range (refunds net out — they carry + * negative amounts). In port currency. */ + revenueCollected: number; + depositsCollected: number; + balanceCollected: number; + /** Absolute value of refund-type payments issued in range. */ + refundsIssued: number; + /** Σ expected deposit across active (open, non-archived) deals — the + * pipeline forecast in deposit terms. */ + pipelineExpected: number; + /** Σ (expected − collected) across active deals, floored at 0 per deal. + * The payments-model analogue of outstanding AR. */ + expectedDepositsOutstanding: number; + /** Σ expenses booked in range, normalised to port currency. */ + expensesTotal: number; + /** revenueCollected − expensesTotal. */ + netContribution: number; + currency: string; +} + +/** Per-deal deposit position — expected vs collected. Internal; feeds the + * pipeline/outstanding KPIs, the aging chart, and the outstanding table. */ +interface DepositPosition { + interestId: string; + clientName: string; + mooring: string | null; + expected: number; + collected: number; + remaining: number; + currency: string; + daysOutstanding: number; +} + +/** + * Expected-vs-collected deposit position for every active deal that has + * an expected deposit set. Collected = Σ deposit-type payments for that + * interest. Amounts normalised to port currency. Age measured from deal + * creation (no invoice due dates exist in the payments model). + */ +async function getDepositPositions(portId: string): Promise { + const targetCurrency = await resolvePortCurrency(portId); + const now = Date.now(); + + const rows = await db + .select({ + interestId: interests.id, + clientName: clients.fullName, + mooring: berths.mooringNumber, + expected: interests.depositExpectedAmount, + expectedCurrency: interests.depositExpectedCurrency, + createdAt: interests.createdAt, + }) + .from(interests) + .innerJoin(clients, eq(clients.id, interests.clientId)) + .leftJoin( + interestBerths, + and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)), + ) + .leftJoin(berths, eq(interestBerths.berthId, berths.id)) + .where(and(activeInterestsWhere(portId), sql`${interests.depositExpectedAmount} IS NOT NULL`)); + + // Deposits collected per interest (deposit-type payments only). + const collectedRows = await db + .select({ + interestId: payments.interestId, + amount: payments.amount, + currency: payments.currency, + }) + .from(payments) + .where(and(eq(payments.portId, portId), eq(payments.paymentType, 'deposit'))); + + const collectedByInterest = new Map(); + for (const r of collectedRows) { + const amount = await normalizeAmount( + Number(r.amount ?? 0), + r.currency ?? targetCurrency, + targetCurrency, + ); + collectedByInterest.set(r.interestId, (collectedByInterest.get(r.interestId) ?? 0) + amount); + } + + const positions: DepositPosition[] = []; + for (const r of rows) { + const expected = await normalizeAmount( + Number(r.expected ?? 0), + r.expectedCurrency ?? targetCurrency, + targetCurrency, + ); + const collected = collectedByInterest.get(r.interestId) ?? 0; + const remaining = Math.max(0, expected - collected); + const daysOutstanding = r.createdAt + ? Math.floor((now - new Date(r.createdAt).getTime()) / 86_400_000) + : 0; + positions.push({ + interestId: r.interestId, + clientName: r.clientName, + mooring: r.mooring, + expected, + collected, + remaining, + currency: targetCurrency, + daysOutstanding, + }); + } + return positions; +} + +/** Σ of all payments in range, bucketed by payment type, normalised. */ +async function sumPaymentsInRange( + portId: string, + range: DateRange, + targetCurrency: string, +): Promise<{ total: number; deposit: number; balance: number; refund: number }> { + const rows = await db + .select({ + paymentType: payments.paymentType, + amount: payments.amount, + currency: payments.currency, + }) + .from(payments) + .where( + and( + eq(payments.portId, portId), + gte(payments.receivedAt, range.from), + lte(payments.receivedAt, range.to), + ), + ); + + const acc = { total: 0, deposit: 0, balance: 0, refund: 0 }; + for (const r of rows) { + const amount = await normalizeAmount( + Number(r.amount ?? 0), + r.currency ?? targetCurrency, + targetCurrency, + ); + acc.total += amount; + if (r.paymentType === 'deposit') acc.deposit += amount; + else if (r.paymentType === 'balance') acc.balance += amount; + else if (r.paymentType === 'refund') acc.refund += amount; // already negative + } + return acc; +} + +export async function getFinancialKpis(portId: string, range: DateRange): Promise { + const currency = await resolvePortCurrency(portId); + const [paymentsAgg, positions, expensesTotal] = await Promise.all([ + sumPaymentsInRange(portId, range, currency), + getDepositPositions(portId), + sumExpensesInRange(portId, range, currency), + ]); + + const pipelineExpected = positions.reduce((s, p) => s + p.expected, 0); + const expectedDepositsOutstanding = positions.reduce((s, p) => s + p.remaining, 0); + + return { + revenueCollected: paymentsAgg.total, + depositsCollected: paymentsAgg.deposit, + balanceCollected: paymentsAgg.balance, + refundsIssued: Math.abs(paymentsAgg.refund), + pipelineExpected, + expectedDepositsOutstanding, + expensesTotal, + netContribution: netContribution(paymentsAgg.total, expensesTotal), + currency, + }; +} + +async function sumExpensesInRange( + portId: string, + range: DateRange, + targetCurrency: string, +): Promise { + const rows = await db + .select({ amount: expenses.amount, currency: expenses.currency }) + .from(expenses) + .where( + and( + eq(expenses.portId, portId), + isNull(expenses.archivedAt), + gte(expenses.expenseDate, range.from), + lte(expenses.expenseDate, range.to), + ), + ); + let total = 0; + for (const r of rows) { + total += await normalizeAmount( + Number(r.amount ?? 0), + r.currency ?? targetCurrency, + targetCurrency, + ); + } + return total; +} + +// ─── Charts ────────────────────────────────────────────────────────────────── + +export interface RevenueByMonthRow { + month: string; // YYYY-MM + deposit: number; + balance: number; +} + +/** Monthly collected revenue, split deposit vs balance. Continuous month + * axis (zero-filled) over the selected range, normalised to port currency. */ +export async function getRevenueByMonth( + portId: string, + range: DateRange, +): Promise { + const currency = await resolvePortCurrency(portId); + const rows = await db + .select({ + paymentType: payments.paymentType, + amount: payments.amount, + paymentCurrency: payments.currency, + receivedAt: payments.receivedAt, + }) + .from(payments) + .where( + and( + eq(payments.portId, portId), + gte(payments.receivedAt, range.from), + lte(payments.receivedAt, range.to), + ), + ); + + const byMonth = new Map(); + for (const key of monthRange(range.from, range.to)) byMonth.set(key, { deposit: 0, balance: 0 }); + for (const r of rows) { + if (!r.receivedAt) continue; + const key = monthKey(new Date(r.receivedAt)); + const bucket = byMonth.get(key); + if (!bucket) continue; + const amount = await normalizeAmount( + Number(r.amount ?? 0), + r.paymentCurrency ?? currency, + currency, + ); + if (r.paymentType === 'balance') bucket.balance += amount; + else if (r.paymentType !== 'refund') bucket.deposit += amount; // deposit + other → deposit bar + } + return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v })); +} + +export interface CollectionFunnelRow { + stage: 'eoi' | 'deposit' | 'contract' | 'won'; + label: string; + count: number; +} + +/** Money funnel: EOI sent → deposit received → contract reached → won, + * counting distinct deals reaching each step within the range. */ +export async function getCollectionFunnel( + portId: string, + range: DateRange, +): Promise { + const [eoiRow] = await db + .select({ count: sql`count(*)::int` }) + .from(interests) + .where( + and( + eq(interests.portId, portId), + gte(interests.dateEoiSent, range.from), + lte(interests.dateEoiSent, range.to), + ), + ); + + const [depositRow] = await db + .select({ count: sql`count(distinct ${payments.interestId})::int` }) + .from(payments) + .where( + and( + eq(payments.portId, portId), + eq(payments.paymentType, 'deposit'), + gte(payments.receivedAt, range.from), + lte(payments.receivedAt, range.to), + ), + ); + + const [contractRow] = await db + .select({ count: sql`count(*)::int` }) + .from(interests) + .where( + and( + eq(interests.portId, portId), + sql`${interests.pipelineStage} = 'contract'`, + isNull(interests.archivedAt), + ), + ); + + const [wonRow] = await db + .select({ count: sql`count(*)::int` }) + .from(interests) + .where( + and( + eq(interests.portId, portId), + eq(interests.outcome, 'won'), + gte(interests.outcomeAt, range.from), + lte(interests.outcomeAt, range.to), + ), + ); + + return [ + { stage: 'eoi', label: 'EOI sent', count: Number(eoiRow?.count ?? 0) }, + { stage: 'deposit', label: 'Deposit received', count: Number(depositRow?.count ?? 0) }, + { stage: 'contract', label: 'At contract', count: Number(contractRow?.count ?? 0) }, + { stage: 'won', label: 'Won', count: Number(wonRow?.count ?? 0) }, + ]; +} + +export interface AgingRow { + bucket: AgingBucket; + count: number; + value: number; +} + +/** Expected deposits still outstanding, bucketed by deal age. */ +export async function getExpectedDepositAging(portId: string): Promise { + const positions = await getDepositPositions(portId); + const byBucket = new Map(); + for (const b of AGING_BUCKETS) byBucket.set(b, { count: 0, value: 0 }); + for (const p of positions) { + if (p.remaining <= 0) continue; + const bucket = byBucket.get(agingBucket(p.daysOutstanding))!; + bucket.count += 1; + bucket.value += p.remaining; + } + return AGING_BUCKETS.map((bucket) => ({ bucket, ...byBucket.get(bucket)! })); +} + +export interface CashFlowRow { + month: string; + inflow: number; + outflow: number; +} + +/** Monthly inflow (payments received) vs outflow (expenses booked). */ +export async function getCashFlow(portId: string, range: DateRange): Promise { + const currency = await resolvePortCurrency(portId); + const byMonth = new Map(); + for (const key of monthRange(range.from, range.to)) byMonth.set(key, { inflow: 0, outflow: 0 }); + + const paymentRows = await db + .select({ + amount: payments.amount, + currency: payments.currency, + receivedAt: payments.receivedAt, + }) + .from(payments) + .where( + and( + eq(payments.portId, portId), + gte(payments.receivedAt, range.from), + lte(payments.receivedAt, range.to), + ), + ); + for (const r of paymentRows) { + if (!r.receivedAt) continue; + const bucket = byMonth.get(monthKey(new Date(r.receivedAt))); + if (!bucket) continue; + bucket.inflow += await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency); + } + + const expenseRows = await db + .select({ + amount: expenses.amount, + currency: expenses.currency, + expenseDate: expenses.expenseDate, + }) + .from(expenses) + .where( + and( + eq(expenses.portId, portId), + isNull(expenses.archivedAt), + gte(expenses.expenseDate, range.from), + lte(expenses.expenseDate, range.to), + ), + ); + for (const r of expenseRows) { + if (!r.expenseDate) continue; + const bucket = byMonth.get(monthKey(new Date(r.expenseDate))); + if (!bucket) continue; + bucket.outflow += await normalizeAmount( + Number(r.amount ?? 0), + r.currency ?? currency, + currency, + ); + } + + return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v })); +} + +export interface ExpenseBreakdownRow { + category: string; + total: number; +} + +/** Expenses for the period grouped by category, normalised, sorted desc. */ +export async function getExpenseBreakdown( + portId: string, + range: DateRange, +): Promise { + const currency = await resolvePortCurrency(portId); + const rows = await db + .select({ category: expenses.category, amount: expenses.amount, currency: expenses.currency }) + .from(expenses) + .where( + and( + eq(expenses.portId, portId), + isNull(expenses.archivedAt), + gte(expenses.expenseDate, range.from), + lte(expenses.expenseDate, range.to), + ), + ); + const byCategory = new Map(); + for (const r of rows) { + const cat = r.category ?? 'Uncategorised'; + const amount = await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency); + byCategory.set(cat, (byCategory.get(cat) ?? 0) + amount); + } + return Array.from(byCategory.entries()) + .map(([category, total]) => ({ category, total })) + .sort((a, b) => b.total - a.total); +} + +// ─── Tables ────────────────────────────────────────────────────────────────── + +export interface OutstandingDepositRow { + interestId: string; + clientName: string; + mooring: string | null; + expected: number; + collected: number; + remaining: number; + currency: string; + daysOutstanding: number; +} + +/** Active deals with an expected deposit not yet fully collected, sorted + * by remaining amount desc. The "chase these" list. */ +export async function getOutstandingDeposits(portId: string): Promise { + const positions = await getDepositPositions(portId); + return positions + .filter((p) => p.remaining > 0) + .sort((a, b) => b.remaining - a.remaining) + .slice(0, 50); +} + +export interface RecentPaymentRow { + id: string; + receivedAt: string; + clientName: string; + mooring: string | null; + paymentType: string; + amount: number; + currency: string; +} + +export async function getRecentPayments( + portId: string, + range: DateRange, +): Promise { + const currency = await resolvePortCurrency(portId); + const rows = await db + .select({ + id: payments.id, + receivedAt: payments.receivedAt, + clientName: clients.fullName, + mooring: berths.mooringNumber, + paymentType: payments.paymentType, + amount: payments.amount, + paymentCurrency: payments.currency, + }) + .from(payments) + .innerJoin(clients, eq(clients.id, payments.clientId)) + .leftJoin( + interestBerths, + and(eq(interestBerths.interestId, payments.interestId), eq(interestBerths.isPrimary, true)), + ) + .leftJoin(berths, eq(interestBerths.berthId, berths.id)) + .where( + and( + eq(payments.portId, portId), + gte(payments.receivedAt, range.from), + lte(payments.receivedAt, range.to), + ), + ) + .orderBy(desc(payments.receivedAt)) + .limit(50); + + const out: RecentPaymentRow[] = []; + for (const r of rows) { + out.push({ + id: r.id, + receivedAt: r.receivedAt ? new Date(r.receivedAt).toISOString() : '', + clientName: r.clientName, + mooring: r.mooring, + paymentType: r.paymentType, + amount: await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency), + currency, + }); + } + return out; +} + +export interface RefundRow { + id: string; + receivedAt: string; + clientName: string; + amount: number; + currency: string; + notes: string | null; +} + +/** Refund-type payments in range (write-off / refund log analogue). */ +export async function getRefundLog(portId: string, range: DateRange): Promise { + const currency = await resolvePortCurrency(portId); + const rows = await db + .select({ + id: payments.id, + receivedAt: payments.receivedAt, + clientName: clients.fullName, + amount: payments.amount, + paymentCurrency: payments.currency, + notes: payments.notes, + }) + .from(payments) + .innerJoin(clients, eq(clients.id, payments.clientId)) + .where( + and( + eq(payments.portId, portId), + eq(payments.paymentType, 'refund'), + gte(payments.receivedAt, range.from), + lte(payments.receivedAt, range.to), + ), + ) + .orderBy(desc(payments.receivedAt)) + .limit(50); + + const out: RefundRow[] = []; + for (const r of rows) { + out.push({ + id: r.id, + receivedAt: r.receivedAt ? new Date(r.receivedAt).toISOString() : '', + clientName: r.clientName, + amount: Math.abs( + await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency), + ), + currency, + notes: r.notes, + }); + } + return out; +} + +export interface ExpenseLedgerRow { + id: string; + expenseDate: string; + payer: string | null; + category: string | null; + establishmentName: string | null; + amount: number; + currency: string; + paymentStatus: string | null; +} + +export async function getExpenseLedger( + portId: string, + range: DateRange, +): Promise { + const currency = await resolvePortCurrency(portId); + const rows = await db + .select({ + id: expenses.id, + expenseDate: expenses.expenseDate, + payer: expenses.payer, + category: expenses.category, + establishmentName: expenses.establishmentName, + amount: expenses.amount, + expenseCurrency: expenses.currency, + paymentStatus: expenses.paymentStatus, + }) + .from(expenses) + .where( + and( + eq(expenses.portId, portId), + isNull(expenses.archivedAt), + gte(expenses.expenseDate, range.from), + lte(expenses.expenseDate, range.to), + ), + ) + .orderBy(desc(expenses.expenseDate)) + .limit(50); + + const out: ExpenseLedgerRow[] = []; + for (const r of rows) { + out.push({ + id: r.id, + expenseDate: r.expenseDate ? new Date(r.expenseDate).toISOString() : '', + payer: r.payer, + category: r.category, + establishmentName: r.establishmentName, + amount: await normalizeAmount(Number(r.amount ?? 0), r.expenseCurrency ?? currency, currency), + currency, + paymentStatus: r.paymentStatus, + }); + } + return out; +} diff --git a/tests/unit/services/reports/financial-math.test.ts b/tests/unit/services/reports/financial-math.test.ts new file mode 100644 index 00000000..8f275110 --- /dev/null +++ b/tests/unit/services/reports/financial-math.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; + +import { + agingBucket, + AGING_BUCKETS, + monthKey, + monthRange, + netContribution, +} from '@/lib/services/reports/financial-math'; + +describe('agingBucket', () => { + it('buckets day counts into the 4 age bands', () => { + expect(agingBucket(0)).toBe('0-30'); + expect(agingBucket(30)).toBe('0-30'); + expect(agingBucket(31)).toBe('31-60'); + expect(agingBucket(60)).toBe('31-60'); + expect(agingBucket(61)).toBe('61-90'); + expect(agingBucket(90)).toBe('61-90'); + expect(agingBucket(91)).toBe('90+'); + expect(agingBucket(400)).toBe('90+'); + }); + + it('exposes the buckets in ascending order for stable chart rows', () => { + expect(AGING_BUCKETS).toEqual(['0-30', '31-60', '61-90', '90+']); + }); +}); + +describe('monthKey', () => { + it('formats a date as zero-padded YYYY-MM (UTC)', () => { + expect(monthKey(new Date('2026-01-09T00:00:00Z'))).toBe('2026-01'); + expect(monthKey(new Date('2026-12-31T23:59:59Z'))).toBe('2026-12'); + }); +}); + +describe('monthRange', () => { + it('produces a continuous ascending month series, inclusive of both ends', () => { + const series = monthRange(new Date('2025-11-15T00:00:00Z'), new Date('2026-02-02T00:00:00Z')); + expect(series).toEqual(['2025-11', '2025-12', '2026-01', '2026-02']); + }); + + it('returns a single month when from and to share a month', () => { + expect(monthRange(new Date('2026-03-01T00:00:00Z'), new Date('2026-03-28T00:00:00Z'))).toEqual([ + '2026-03', + ]); + }); +}); + +describe('netContribution', () => { + it('is revenue minus expenses', () => { + expect(netContribution(1000, 250)).toBe(750); + expect(netContribution(100, 400)).toBe(-300); + }); +});