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

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

View File

@@ -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<string> {
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<number> {
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;
}

View File

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

View File

@@ -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<DepositPosition[]> {
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<string, number>();
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<FinancialKpis> {
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<number> {
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<RevenueByMonthRow[]> {
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<string, { deposit: number; balance: number }>();
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<CollectionFunnelRow[]> {
const [eoiRow] = await db
.select({ count: sql<number>`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<number>`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<number>`count(*)::int` })
.from(interests)
.where(
and(
eq(interests.portId, portId),
sql`${interests.pipelineStage} = 'contract'`,
isNull(interests.archivedAt),
),
);
const [wonRow] = await db
.select({ count: sql<number>`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<AgingRow[]> {
const positions = await getDepositPositions(portId);
const byBucket = new Map<AgingBucket, { count: number; value: number }>();
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<CashFlowRow[]> {
const currency = await resolvePortCurrency(portId);
const byMonth = new Map<string, { inflow: number; outflow: number }>();
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<ExpenseBreakdownRow[]> {
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<string, number>();
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<OutstandingDepositRow[]> {
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<RecentPaymentRow[]> {
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<RefundRow[]> {
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<ExpenseLedgerRow[]> {
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;
}