feat(reports): Financial report (Initiative 1 Phase 4)

Builds the Financial report on the canonical payments + expenses tables
(the CRM records money received; it does not invoice — invoices module
is off, dev DB has zero invoice rows). The invoice-centric spec is
reframed onto the payments model: "outstanding AR" → expected-deposit
shortfall on active deals; "AR aging" → outstanding deposits bucketed by
deal age.

Service (financial.service.ts):
- 7 KPIs: revenue collected (net of refunds), deposits, balance,
  pipeline expected, outstanding deposits, expenses, net contribution
- 6 chart datasets: revenue by month (deposit/balance), collection
  funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow
  (inflow vs outflow), expense breakdown by category
- 4 tables: outstanding deposits, recent payments, refund log, expense
  ledger
- every money figure normalised to port currency via a shared
  resolvePortCurrency/normalizeAmount helper (new reports/currency.ts)

UI (financial-report-client.tsx): KPI strip + recharts (stacked bar /
horizontal bar / line / donut) + month/quarter/year toggle + branded
empty states; DateRangePicker + Templates + Export wired. Un-hidden the
Financial card on the reports landing.

Plumbing: added '1y' (trailing 12mo) preset to the shared range system
(financial trends want a year); added 'financial'/'marketing' to the
report-template kind enum for template parity.

TDD: 6 financial-math unit tests (aging buckets, month keys/range, net
contribution). tsc clean; full unit suite green except pre-existing
Redis/storage-dependent integration tests. Browser-verified against live
data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue
correct given 0 payment rows), expense ledger + breakdown populate,
payment-derived sections show graceful empty states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 00:43:36 +02:00
parent 75fdb9fab4
commit b690fb8d56
14 changed files with 1769 additions and 8 deletions

View File

@@ -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 <FinancialReportClient portSlug={portSlug} />;
}

View File

@@ -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',

View File

@@ -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);
}
}),
);

View File

@@ -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